clishop 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "clishop",
3
+ "version": "0.1.0",
4
+ "description": "CLISHOP — Order anything from your terminal",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "clishop": "dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsup src/index.ts --format esm --dts --clean --target node18",
11
+ "dev": "tsx src/index.ts",
12
+ "start": "node dist/index.js",
13
+ "lint": "tsc --noEmit"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/DavooxBv2/CLISHOP.git"
18
+ },
19
+ "keywords": [
20
+ "cli",
21
+ "shopping",
22
+ "ordering"
23
+ ],
24
+ "author": "",
25
+ "license": "ISC",
26
+ "type": "module",
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/DavooxBv2/CLISHOP/issues"
32
+ },
33
+ "homepage": "https://github.com/DavooxBv2/CLISHOP#readme",
34
+ "dependencies": {
35
+ "axios": "^1.13.5",
36
+ "chalk": "^5.6.2",
37
+ "commander": "^14.0.3",
38
+ "conf": "^15.1.0",
39
+ "inquirer": "^13.2.4",
40
+ "keytar": "^7.9.0",
41
+ "ora": "^9.3.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/inquirer": "^9.0.9",
45
+ "@types/keytar": "^4.4.0",
46
+ "@types/node": "^25.2.3",
47
+ "ts-node": "^10.9.2",
48
+ "tsup": "^8.5.1",
49
+ "tsx": "^4.21.0",
50
+ "typescript": "^5.9.3"
51
+ }
52
+ }
package/src/api.ts ADDED
@@ -0,0 +1,89 @@
1
+ import axios, { AxiosInstance, AxiosError } from "axios";
2
+ import chalk from "chalk";
3
+ import { getToken, getRefreshToken, storeToken } from "./auth.js";
4
+
5
+ let client: AxiosInstance | null = null;
6
+
7
+ /**
8
+ * Get an authenticated Axios client that talks to the backend.
9
+ * Automatically attaches the Bearer token and handles 401 refresh.
10
+ */
11
+ const API_BASE_URL = process.env.CLISHOP_API_URL || "https://clishop-backend.vercel.app/api";
12
+
13
+ export function getApiClient(): AxiosInstance {
14
+ if (client) return client;
15
+
16
+ const baseUrl = API_BASE_URL;
17
+
18
+ client = axios.create({
19
+ baseURL: baseUrl,
20
+ timeout: 30_000,
21
+ headers: {
22
+ "Content-Type": "application/json",
23
+ },
24
+ });
25
+
26
+ // Attach token to every request
27
+ client.interceptors.request.use(async (reqConfig) => {
28
+ const token = await getToken();
29
+ if (token) {
30
+ reqConfig.headers.Authorization = `Bearer ${token}`;
31
+ }
32
+ return reqConfig;
33
+ });
34
+
35
+ // Handle 401 — try refresh
36
+ client.interceptors.response.use(
37
+ (res) => res,
38
+ async (error: AxiosError) => {
39
+ if (error.response?.status === 401) {
40
+ const refreshToken = await getRefreshToken();
41
+ if (refreshToken) {
42
+ try {
43
+ const res = await axios.post(`${baseUrl}/auth/refresh`, { refreshToken });
44
+ await storeToken(res.data.token);
45
+ // Retry original request
46
+ if (error.config) {
47
+ error.config.headers.Authorization = `Bearer ${res.data.token}`;
48
+ return axios(error.config);
49
+ }
50
+ } catch {
51
+ // Refresh failed — user needs to login again
52
+ }
53
+ }
54
+ console.error(chalk.red("\n✗ Session expired. Please login again: clishop login\n"));
55
+ process.exit(1);
56
+ }
57
+ throw error;
58
+ }
59
+ );
60
+
61
+ return client;
62
+ }
63
+
64
+ /**
65
+ * Handle API errors consistently.
66
+ */
67
+ export function handleApiError(error: unknown): never {
68
+ if (axios.isAxiosError(error)) {
69
+ const data = error.response?.data;
70
+ const message = data?.message || data?.error || error.message;
71
+ const status = error.response?.status;
72
+
73
+ if (status === 422 && data?.errors) {
74
+ console.error(chalk.red("\n✗ Validation errors:"));
75
+ for (const [field, msgs] of Object.entries(data.errors)) {
76
+ console.error(chalk.red(` ${field}: ${(msgs as string[]).join(", ")}`));
77
+ }
78
+ } else if (status === 404) {
79
+ console.error(chalk.red(`\n✗ Not found: ${message}`));
80
+ } else {
81
+ console.error(chalk.red(`\n✗ API error (${status || "network"}): ${message}`));
82
+ }
83
+ } else if (error instanceof Error) {
84
+ console.error(chalk.red(`\n✗ ${error.message}`));
85
+ } else {
86
+ console.error(chalk.red("\n✗ An unexpected error occurred."));
87
+ }
88
+ process.exit(1);
89
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,117 @@
1
+ import keytar from "keytar";
2
+ import axios from "axios";
3
+ import { getConfig } from "./config.js";
4
+
5
+ const SERVICE_NAME = "clishop";
6
+ const ACCOUNT_TOKEN = "auth-token";
7
+ const ACCOUNT_REFRESH = "refresh-token";
8
+ const ACCOUNT_USER = "user-info";
9
+
10
+ export interface UserInfo {
11
+ id: string;
12
+ email: string;
13
+ name: string;
14
+ }
15
+
16
+ /**
17
+ * Store auth token securely in the OS keychain.
18
+ */
19
+ export async function storeToken(token: string): Promise<void> {
20
+ await keytar.setPassword(SERVICE_NAME, ACCOUNT_TOKEN, token);
21
+ }
22
+
23
+ export async function storeRefreshToken(token: string): Promise<void> {
24
+ await keytar.setPassword(SERVICE_NAME, ACCOUNT_REFRESH, token);
25
+ }
26
+
27
+ export async function storeUserInfo(user: UserInfo): Promise<void> {
28
+ await keytar.setPassword(SERVICE_NAME, ACCOUNT_USER, JSON.stringify(user));
29
+ }
30
+
31
+ export async function getToken(): Promise<string | null> {
32
+ return keytar.getPassword(SERVICE_NAME, ACCOUNT_TOKEN);
33
+ }
34
+
35
+ export async function getRefreshToken(): Promise<string | null> {
36
+ return keytar.getPassword(SERVICE_NAME, ACCOUNT_REFRESH);
37
+ }
38
+
39
+ export async function getUserInfo(): Promise<UserInfo | null> {
40
+ const raw = await keytar.getPassword(SERVICE_NAME, ACCOUNT_USER);
41
+ if (!raw) return null;
42
+ try {
43
+ return JSON.parse(raw) as UserInfo;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ export async function clearAuth(): Promise<void> {
50
+ await keytar.deletePassword(SERVICE_NAME, ACCOUNT_TOKEN);
51
+ await keytar.deletePassword(SERVICE_NAME, ACCOUNT_REFRESH);
52
+ await keytar.deletePassword(SERVICE_NAME, ACCOUNT_USER);
53
+ }
54
+
55
+ export async function isLoggedIn(): Promise<boolean> {
56
+ const token = await getToken();
57
+ return !!token;
58
+ }
59
+
60
+ /**
61
+ * Login with email + password.
62
+ * Returns the user info on success.
63
+ */
64
+ export async function login(email: string, password: string): Promise<UserInfo> {
65
+ const config = getConfig();
66
+ const baseUrl = config.get("apiBaseUrl");
67
+
68
+ const res = await axios.post(`${baseUrl}/auth/login`, { email, password });
69
+
70
+ const { token, refreshToken, user } = res.data;
71
+
72
+ await storeToken(token);
73
+ if (refreshToken) await storeRefreshToken(refreshToken);
74
+ await storeUserInfo(user);
75
+
76
+ return user;
77
+ }
78
+
79
+ /**
80
+ * Register a new account.
81
+ */
82
+ export async function register(email: string, password: string, name: string): Promise<UserInfo> {
83
+ const config = getConfig();
84
+ const baseUrl = config.get("apiBaseUrl");
85
+
86
+ const res = await axios.post(`${baseUrl}/auth/register`, { email, password, name });
87
+
88
+ const { token, refreshToken, user } = res.data;
89
+
90
+ await storeToken(token);
91
+ if (refreshToken) await storeRefreshToken(refreshToken);
92
+ await storeUserInfo(user);
93
+
94
+ return user;
95
+ }
96
+
97
+ /**
98
+ * Logout — clear local tokens.
99
+ */
100
+ export async function logout(): Promise<void> {
101
+ const config = getConfig();
102
+ const baseUrl = config.get("apiBaseUrl");
103
+ const token = await getToken();
104
+
105
+ // Best-effort server-side logout
106
+ if (token) {
107
+ try {
108
+ await axios.post(`${baseUrl}/auth/logout`, {}, {
109
+ headers: { Authorization: `Bearer ${token}` },
110
+ });
111
+ } catch {
112
+ // Server might be unreachable — that's fine, we clear locally anyway
113
+ }
114
+ }
115
+
116
+ await clearAuth();
117
+ }
@@ -0,0 +1,213 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import ora from "ora";
4
+ import inquirer from "inquirer";
5
+ import { getApiClient, handleApiError } from "../api.js";
6
+ import { getConfig, getActiveAgent, updateAgent } from "../config.js";
7
+
8
+ export interface Address {
9
+ id: string;
10
+ label: string;
11
+ recipientName?: string;
12
+ recipientPhone?: string;
13
+ companyName?: string;
14
+ vatNumber?: string;
15
+ taxId?: string;
16
+ line1: string;
17
+ line2?: string;
18
+ city: string;
19
+ state?: string;
20
+ region?: string;
21
+ postalCode: string;
22
+ country: string;
23
+ instructions?: string;
24
+ }
25
+
26
+ export function registerAddressCommands(program: Command): void {
27
+ const address = program
28
+ .command("address")
29
+ .description("Manage shipping addresses (scoped to the active agent)");
30
+
31
+ // ── LIST ───────────────────────────────────────────────────────────
32
+ address
33
+ .command("list")
34
+ .alias("ls")
35
+ .description("List all addresses for the active agent")
36
+ .action(async () => {
37
+ try {
38
+ const agent = getActiveAgent();
39
+ const spinner = ora("Fetching addresses...").start();
40
+ const api = getApiClient();
41
+ const res = await api.get("/addresses", {
42
+ params: { agent: agent.name },
43
+ });
44
+ spinner.stop();
45
+
46
+ const addresses: Address[] = res.data.addresses;
47
+ if (addresses.length === 0) {
48
+ console.log(chalk.yellow("\nNo addresses found. Add one with: clishop address add\n"));
49
+ return;
50
+ }
51
+
52
+ console.log(chalk.bold(`\nAddresses for agent "${agent.name}":\n`));
53
+ for (const addr of addresses) {
54
+ const isDefault = addr.id === agent.defaultAddressId;
55
+ const marker = isDefault ? chalk.green("● ") : " ";
56
+ console.log(`${marker}${chalk.bold(addr.label)} ${chalk.dim(`(${addr.id})`)}`);
57
+ if (addr.recipientName) console.log(` ${addr.recipientName}`);
58
+ if (addr.companyName) console.log(` ${chalk.cyan(addr.companyName)}`);
59
+ console.log(` ${addr.line1}`);
60
+ if (addr.line2) console.log(` ${addr.line2}`);
61
+ console.log(` ${addr.city}${addr.region ? `, ${addr.region}` : ""} ${addr.postalCode}`);
62
+ console.log(` ${addr.country}`);
63
+ if (addr.recipientPhone) console.log(` ${chalk.dim("Phone:")} ${addr.recipientPhone}`);
64
+ if (addr.vatNumber) console.log(` ${chalk.dim("VAT:")} ${addr.vatNumber}`);
65
+ if (addr.taxId) console.log(` ${chalk.dim("Tax ID:")} ${addr.taxId}`);
66
+ if (addr.instructions) console.log(` ${chalk.dim("Instructions:")} ${addr.instructions}`);
67
+ console.log();
68
+ }
69
+ } catch (error) {
70
+ handleApiError(error);
71
+ }
72
+ });
73
+
74
+ // ── ADD ────────────────────────────────────────────────────────────
75
+ address
76
+ .command("add")
77
+ .description("Add a new address")
78
+ .action(async () => {
79
+ try {
80
+ const agent = getActiveAgent();
81
+ const answers = await inquirer.prompt([
82
+ { type: "input", name: "label", message: "Label (e.g. Home, Office):" },
83
+ { type: "input", name: "recipientName", message: "Recipient name (optional):" },
84
+ { type: "input", name: "recipientPhone", message: "Recipient phone (optional):" },
85
+ {
86
+ type: "input",
87
+ name: "line1",
88
+ message: "Street name and number:",
89
+ validate: (v: string) => (v.trim() ? true : "Required"),
90
+ },
91
+ { type: "input", name: "line2", message: "Apartment, suite, floor, etc. (optional):" },
92
+ {
93
+ type: "input",
94
+ name: "postalCode",
95
+ message: "Postal / ZIP code:",
96
+ validate: (v: string) => (v.trim() ? true : "Required"),
97
+ },
98
+ {
99
+ type: "input",
100
+ name: "city",
101
+ message: "City:",
102
+ validate: (v: string) => (v.trim() ? true : "Required"),
103
+ },
104
+ { type: "input", name: "region", message: "State / Province / Region (optional):" },
105
+ {
106
+ type: "input",
107
+ name: "country",
108
+ message: "Country:",
109
+ validate: (v: string) => (v.trim() ? true : "Required"),
110
+ },
111
+ { type: "input", name: "instructions", message: "Delivery instructions (optional):" },
112
+ {
113
+ type: "confirm",
114
+ name: "isCompany",
115
+ message: "Is this a company/business address?",
116
+ default: false,
117
+ },
118
+ ]);
119
+
120
+ let companyAnswers = { companyName: "", vatNumber: "", taxId: "" };
121
+ if (answers.isCompany) {
122
+ companyAnswers = await inquirer.prompt([
123
+ {
124
+ type: "input",
125
+ name: "companyName",
126
+ message: "Company name:",
127
+ validate: (v: string) => (v.trim() ? true : "Required for company addresses"),
128
+ },
129
+ { type: "input", name: "vatNumber", message: "VAT number (optional):" },
130
+ { type: "input", name: "taxId", message: "Tax ID / EIN (optional):" },
131
+ ]);
132
+ }
133
+
134
+ const { setDefault } = await inquirer.prompt([
135
+ {
136
+ type: "confirm",
137
+ name: "setDefault",
138
+ message: "Set as default address for this agent?",
139
+ default: true,
140
+ },
141
+ ]);
142
+
143
+ const spinner = ora("Saving address...").start();
144
+ const api = getApiClient();
145
+ const res = await api.post("/addresses", {
146
+ agent: agent.name,
147
+ label: answers.label,
148
+ recipientName: answers.recipientName || undefined,
149
+ recipientPhone: answers.recipientPhone || undefined,
150
+ companyName: companyAnswers.companyName || undefined,
151
+ vatNumber: companyAnswers.vatNumber || undefined,
152
+ taxId: companyAnswers.taxId || undefined,
153
+ line1: answers.line1,
154
+ line2: answers.line2 || undefined,
155
+ city: answers.city,
156
+ region: answers.region || undefined,
157
+ postalCode: answers.postalCode,
158
+ country: answers.country,
159
+ instructions: answers.instructions || undefined,
160
+ });
161
+
162
+ if (setDefault) {
163
+ updateAgent(agent.name, { defaultAddressId: res.data.address.id });
164
+ }
165
+
166
+ spinner.succeed(chalk.green(`Address "${answers.label}" added.`));
167
+ } catch (error) {
168
+ handleApiError(error);
169
+ }
170
+ });
171
+
172
+ // ── REMOVE ─────────────────────────────────────────────────────────
173
+ address
174
+ .command("remove <id>")
175
+ .alias("rm")
176
+ .description("Remove an address by ID")
177
+ .action(async (id: string) => {
178
+ try {
179
+ const { confirm } = await inquirer.prompt([
180
+ {
181
+ type: "confirm",
182
+ name: "confirm",
183
+ message: `Delete address ${id}?`,
184
+ default: false,
185
+ },
186
+ ]);
187
+ if (!confirm) return;
188
+
189
+ const spinner = ora("Removing address...").start();
190
+ const api = getApiClient();
191
+ await api.delete(`/addresses/${id}`);
192
+ spinner.succeed(chalk.green("Address removed."));
193
+
194
+ // Clear default if it was this one
195
+ const agent = getActiveAgent();
196
+ if (agent.defaultAddressId === id) {
197
+ updateAgent(agent.name, { defaultAddressId: undefined });
198
+ }
199
+ } catch (error) {
200
+ handleApiError(error);
201
+ }
202
+ });
203
+
204
+ // ── SET-DEFAULT ────────────────────────────────────────────────────
205
+ address
206
+ .command("set-default <id>")
207
+ .description("Set the default address for the active agent")
208
+ .action((id: string) => {
209
+ const agent = getActiveAgent();
210
+ updateAgent(agent.name, { defaultAddressId: id });
211
+ console.log(chalk.green(`\n✓ Default address for agent "${agent.name}" set to ${id}.`));
212
+ });
213
+ }