@supercycle/cli 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/README.md +107 -0
- package/dist/cli.js +9 -0
- package/dist/commands/comments/delete.js +48 -0
- package/dist/commands/comments/index.js +3 -0
- package/dist/commands/conditions/index.js +3 -0
- package/dist/commands/conditions/list.js +20 -0
- package/dist/commands/custom-fields/definition.js +58 -0
- package/dist/commands/custom-fields/definitions.js +25 -0
- package/dist/commands/custom-fields/delete.js +45 -0
- package/dist/commands/custom-fields/index.js +3 -0
- package/dist/commands/custom-fields/set.js +43 -0
- package/dist/commands/custom-fields/update.js +19 -0
- package/dist/commands/cycles/comment.js +29 -0
- package/dist/commands/cycles/fulfil.js +24 -0
- package/dist/commands/cycles/get.js +50 -0
- package/dist/commands/cycles/index.js +3 -0
- package/dist/commands/cycles/list.js +124 -0
- package/dist/commands/cycles/overdue.js +25 -0
- package/dist/commands/cycles/pack.js +21 -0
- package/dist/commands/cycles/reassign.js +28 -0
- package/dist/commands/cycles/receive.js +23 -0
- package/dist/commands/cycles/reschedule.js +74 -0
- package/dist/commands/cycles/return.js +52 -0
- package/dist/commands/cycles/tag.js +45 -0
- package/dist/commands/cycles/to-fulfill.js +9 -0
- package/dist/commands/cycles/to-receive.js +9 -0
- package/dist/commands/cycles/today.js +18 -0
- package/dist/commands/items/availability.js +75 -0
- package/dist/commands/items/comment.js +29 -0
- package/dist/commands/items/create.js +57 -0
- package/dist/commands/items/get.js +50 -0
- package/dist/commands/items/index.js +3 -0
- package/dist/commands/items/list.js +25 -0
- package/dist/commands/items/update.js +60 -0
- package/dist/commands/locations/index.js +3 -0
- package/dist/commands/locations/list.js +19 -0
- package/dist/commands/login.js +76 -0
- package/dist/commands/products/import.js +65 -0
- package/dist/commands/products/index.js +3 -0
- package/dist/commands/products/list.js +19 -0
- package/dist/commands/returns/comment.js +29 -0
- package/dist/commands/returns/get.js +50 -0
- package/dist/commands/returns/index.js +3 -0
- package/dist/commands/returns/list.js +27 -0
- package/dist/commands/returns/update.js +68 -0
- package/dist/components/CommentResultView.js +40 -0
- package/dist/components/CustomFieldResultView.js +46 -0
- package/dist/components/CycleActionView.js +36 -0
- package/dist/components/CycleDetail.js +60 -0
- package/dist/components/CycleTable.js +68 -0
- package/dist/components/CyclesListView.js +30 -0
- package/dist/components/ErrorMessage.js +13 -0
- package/dist/components/ExitError.js +13 -0
- package/dist/components/ItemActionView.js +36 -0
- package/dist/components/ItemDetail.js +48 -0
- package/dist/components/ItemTable.js +58 -0
- package/dist/components/ItemsListView.js +30 -0
- package/dist/components/ItemsResultView.js +37 -0
- package/dist/components/JsonOutput.js +12 -0
- package/dist/components/Loading.js +11 -0
- package/dist/components/ProductTable.js +49 -0
- package/dist/components/ProductsListView.js +30 -0
- package/dist/components/ReferenceListView.js +49 -0
- package/dist/components/ReturnActionView.js +36 -0
- package/dist/components/ReturnDetail.js +55 -0
- package/dist/components/ReturnResultView.js +49 -0
- package/dist/components/ReturnTable.js +58 -0
- package/dist/components/ReturnsListView.js +30 -0
- package/dist/components/Status.js +33 -0
- package/dist/hooks/useAsync.js +32 -0
- package/dist/lib/client.js +302 -0
- package/dist/lib/config.js +38 -0
- package/dist/lib/format.js +49 -0
- package/dist/lib/listViewOptions.js +39 -0
- package/dist/lib/resolveClient.js +11 -0
- package/dist/lib/types.js +3 -0
- package/package.json +44 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Text } from "ink";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { titleCase } from "../lib/format.js";
|
|
4
|
+
// Map any status enum value to a sensible color.
|
|
5
|
+
function colorFor(status) {
|
|
6
|
+
switch (status) {
|
|
7
|
+
case "complete":
|
|
8
|
+
case "fulfilled":
|
|
9
|
+
case "received":
|
|
10
|
+
case "packed":
|
|
11
|
+
return "green";
|
|
12
|
+
case "overdue":
|
|
13
|
+
return "red";
|
|
14
|
+
case "in_progress":
|
|
15
|
+
case "printed":
|
|
16
|
+
return "yellow";
|
|
17
|
+
case "scheduled":
|
|
18
|
+
case "due":
|
|
19
|
+
case "unfulfilled":
|
|
20
|
+
case "unreceived":
|
|
21
|
+
return "cyan";
|
|
22
|
+
case "cancelled":
|
|
23
|
+
case "pending":
|
|
24
|
+
return "gray";
|
|
25
|
+
default:
|
|
26
|
+
return "white";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export default function Status({ value }) {
|
|
30
|
+
if (!value)
|
|
31
|
+
return (React.createElement(Text, { dimColor: true, wrap: "truncate" }, "\u2014"));
|
|
32
|
+
return (React.createElement(Text, { color: colorFor(value), wrap: "truncate" }, titleCase(value)));
|
|
33
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// This is a generic hook that forwards a caller-supplied `deps` array to
|
|
2
|
+
// useEffect, so the dependencies can't be a static literal here.
|
|
3
|
+
/* oxlint-disable react-hooks/exhaustive-deps */
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
// Run an async function once on mount (and when deps change), tracking
|
|
6
|
+
// loading/data/error state. Ignores results after unmount.
|
|
7
|
+
export function useAsync(fn, deps) {
|
|
8
|
+
const [state, setState] = useState({ loading: true });
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
let cancelled = false;
|
|
11
|
+
setState({ loading: true });
|
|
12
|
+
void (async () => {
|
|
13
|
+
try {
|
|
14
|
+
const data = await fn();
|
|
15
|
+
if (!cancelled)
|
|
16
|
+
setState({ loading: false, data });
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (!cancelled) {
|
|
20
|
+
setState({
|
|
21
|
+
loading: false,
|
|
22
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
})();
|
|
27
|
+
return () => {
|
|
28
|
+
cancelled = true;
|
|
29
|
+
};
|
|
30
|
+
}, deps);
|
|
31
|
+
return state;
|
|
32
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
export const DEFAULT_BASE_URL = "https://app.supercycle.com/api/v1";
|
|
2
|
+
export class ApiError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
body;
|
|
5
|
+
retryAfter;
|
|
6
|
+
constructor(message, options) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "ApiError";
|
|
9
|
+
this.status = options.status;
|
|
10
|
+
this.body = options.body;
|
|
11
|
+
this.retryAfter = options.retryAfter;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function isDateRange(value) {
|
|
15
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
// Serialize filters into query params. Date-range objects use bracket
|
|
18
|
+
// notation (e.g. `rentalStart[gte]=2024-01-01`), as the API expects.
|
|
19
|
+
function buildQuery(params) {
|
|
20
|
+
const search = new URLSearchParams();
|
|
21
|
+
for (const [key, value] of Object.entries(params)) {
|
|
22
|
+
if (value === undefined || value === null || value === "") {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (isDateRange(value)) {
|
|
26
|
+
for (const op of ["gt", "lt", "gte", "lte"]) {
|
|
27
|
+
const opValue = value[op];
|
|
28
|
+
if (opValue !== undefined && opValue !== "") {
|
|
29
|
+
search.append(`${key}[${op}]`, opValue);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
search.append(key, String(value));
|
|
35
|
+
}
|
|
36
|
+
const qs = search.toString();
|
|
37
|
+
return qs ? `?${qs}` : "";
|
|
38
|
+
}
|
|
39
|
+
export class SupercycleClient {
|
|
40
|
+
token;
|
|
41
|
+
baseUrl;
|
|
42
|
+
constructor(options) {
|
|
43
|
+
this.token = options.token;
|
|
44
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
45
|
+
}
|
|
46
|
+
async request(path, options = {}) {
|
|
47
|
+
const { method = "GET", query = {}, body } = options;
|
|
48
|
+
const url = `${this.baseUrl}${path}${buildQuery(query)}`;
|
|
49
|
+
const headers = {
|
|
50
|
+
Authorization: `Bearer ${this.token}`,
|
|
51
|
+
Accept: "application/json",
|
|
52
|
+
};
|
|
53
|
+
if (body !== undefined) {
|
|
54
|
+
headers["Content-Type"] = "application/json";
|
|
55
|
+
}
|
|
56
|
+
let response;
|
|
57
|
+
try {
|
|
58
|
+
response = await fetch(url, {
|
|
59
|
+
method,
|
|
60
|
+
headers,
|
|
61
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
throw new ApiError(`Could not reach the Supercycle API: ${error instanceof Error ? error.message : String(error)}`, { status: 0 });
|
|
66
|
+
}
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
throw await this.toApiError(response);
|
|
69
|
+
}
|
|
70
|
+
// 204 No Content and other empty bodies have nothing to parse.
|
|
71
|
+
if (response.status === 204) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
const text = await response.text();
|
|
75
|
+
return (text ? JSON.parse(text) : undefined);
|
|
76
|
+
}
|
|
77
|
+
async toApiError(response) {
|
|
78
|
+
const body = await response.text().catch(() => "");
|
|
79
|
+
switch (response.status) {
|
|
80
|
+
case 401:
|
|
81
|
+
case 403:
|
|
82
|
+
return new ApiError("Invalid or missing API key. Run `supercycle login` to set one.", {
|
|
83
|
+
status: response.status,
|
|
84
|
+
body,
|
|
85
|
+
});
|
|
86
|
+
case 404:
|
|
87
|
+
return new ApiError("Not found.", {
|
|
88
|
+
status: response.status,
|
|
89
|
+
body,
|
|
90
|
+
});
|
|
91
|
+
case 429: {
|
|
92
|
+
const retryAfter = response.headers.get("Retry-After") ?? undefined;
|
|
93
|
+
return new ApiError(`Rate limited by the Supercycle API${retryAfter ? ` — retry after ${retryAfter}s` : ""}.`, { status: response.status, body, retryAfter });
|
|
94
|
+
}
|
|
95
|
+
default:
|
|
96
|
+
return new ApiError(`Supercycle API request failed (${response.status}).${body ? ` ${body}` : ""}`, { status: response.status, body });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// GET /rentals — list cycles with optional filters.
|
|
100
|
+
listCycles(filters = {}) {
|
|
101
|
+
return this.request("/rentals", { query: { ...filters } });
|
|
102
|
+
}
|
|
103
|
+
// Follow keyset pagination to gather every matching cycle. Caps the number
|
|
104
|
+
// of pages as a safety net against an unexpected server response.
|
|
105
|
+
async listAllCycles(filters = {}, maxPages = 1000) {
|
|
106
|
+
const all = [];
|
|
107
|
+
let page = filters.page;
|
|
108
|
+
for (let i = 0; i < maxPages; i++) {
|
|
109
|
+
// Sequential by design: each request needs the previous page's cursor.
|
|
110
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
111
|
+
const res = await this.listCycles({ ...filters, page });
|
|
112
|
+
all.push(...res.data);
|
|
113
|
+
if (!res.nextPage) {
|
|
114
|
+
return { data: all, nextPage: null };
|
|
115
|
+
}
|
|
116
|
+
page = res.nextPage;
|
|
117
|
+
}
|
|
118
|
+
return { data: all, nextPage: null };
|
|
119
|
+
}
|
|
120
|
+
// GET /rentals/{id} — retrieve a single cycle.
|
|
121
|
+
getCycle(id, options = {}) {
|
|
122
|
+
return this.request(`/rentals/${id}`, {
|
|
123
|
+
query: { include: options.include },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
// PUT /rentals/{id} — update a cycle.
|
|
127
|
+
updateCycle(id, body) {
|
|
128
|
+
return this.request(`/rentals/${id}`, { method: "PUT", body });
|
|
129
|
+
}
|
|
130
|
+
// GET /items — list inventory items with optional filters.
|
|
131
|
+
listItems(filters = {}) {
|
|
132
|
+
return this.request("/items", { query: { ...filters } });
|
|
133
|
+
}
|
|
134
|
+
// Follow keyset pagination to gather every matching item. Caps the number of
|
|
135
|
+
// pages as a safety net against an unexpected server response.
|
|
136
|
+
async listAllItems(filters = {}, maxPages = 1000) {
|
|
137
|
+
const all = [];
|
|
138
|
+
let page = filters.page;
|
|
139
|
+
for (let i = 0; i < maxPages; i++) {
|
|
140
|
+
// Sequential by design: each request needs the previous page's cursor.
|
|
141
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
142
|
+
const res = await this.listItems({ ...filters, page });
|
|
143
|
+
all.push(...res.data);
|
|
144
|
+
if (!res.nextPage) {
|
|
145
|
+
return { data: all, nextPage: null };
|
|
146
|
+
}
|
|
147
|
+
page = res.nextPage;
|
|
148
|
+
}
|
|
149
|
+
return { data: all, nextPage: null };
|
|
150
|
+
}
|
|
151
|
+
// POST /items — create one or more items against an imported product.
|
|
152
|
+
// The live API returns a bare array of items (despite the docs showing an
|
|
153
|
+
// `{ items }` wrapper); handle both shapes defensively.
|
|
154
|
+
async createItems(body) {
|
|
155
|
+
const res = await this.request("/items", {
|
|
156
|
+
method: "POST",
|
|
157
|
+
body,
|
|
158
|
+
});
|
|
159
|
+
if (Array.isArray(res))
|
|
160
|
+
return res;
|
|
161
|
+
return Array.isArray(res.items) ? res.items : [res.items];
|
|
162
|
+
}
|
|
163
|
+
// PUT /items/{id} — update an item's serial, condition, status, etc.
|
|
164
|
+
updateItem(id, body) {
|
|
165
|
+
return this.request(`/items/${id}`, { method: "PUT", body });
|
|
166
|
+
}
|
|
167
|
+
// GET /items/{id} — retrieve a single item.
|
|
168
|
+
getItem(id, options = {}) {
|
|
169
|
+
return this.request(`/items/${id}`, {
|
|
170
|
+
query: { include: options.include },
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
// GET /products — list products with optional search.
|
|
174
|
+
listProducts(filters = {}) {
|
|
175
|
+
return this.request("/products", { query: { ...filters } });
|
|
176
|
+
}
|
|
177
|
+
// Follow keyset pagination to gather every matching product. Caps the number
|
|
178
|
+
// of pages as a safety net against an unexpected server response.
|
|
179
|
+
async listAllProducts(filters = {}, maxPages = 1000) {
|
|
180
|
+
const all = [];
|
|
181
|
+
let page = filters.page;
|
|
182
|
+
for (let i = 0; i < maxPages; i++) {
|
|
183
|
+
// Sequential by design: each request needs the previous page's cursor.
|
|
184
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
185
|
+
const res = await this.listProducts({ ...filters, page });
|
|
186
|
+
all.push(...res.data);
|
|
187
|
+
if (!res.nextPage) {
|
|
188
|
+
return { data: all, nextPage: null };
|
|
189
|
+
}
|
|
190
|
+
page = res.nextPage;
|
|
191
|
+
}
|
|
192
|
+
return { data: all, nextPage: null };
|
|
193
|
+
}
|
|
194
|
+
// GET /availability_timelines — per-day availability for a variant.
|
|
195
|
+
// Maps the camelCase input to the snake_case query params the API expects.
|
|
196
|
+
getVariantAvailability(opts) {
|
|
197
|
+
return this.request("/availability_timelines", {
|
|
198
|
+
query: {
|
|
199
|
+
variant_shopify_id: opts.variantShopifyId,
|
|
200
|
+
location_id: opts.locationId,
|
|
201
|
+
delivery_method_type: opts.deliveryMethodType,
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
// POST /products — import Shopify products into Supercycle by ID.
|
|
206
|
+
// The live endpoint returns snake_case `product_ids`; send both casings in
|
|
207
|
+
// the body and normalize the response to camelCase.
|
|
208
|
+
async importProducts(productIds) {
|
|
209
|
+
const res = await this.request("/products", {
|
|
210
|
+
method: "POST",
|
|
211
|
+
body: { productIds, product_ids: productIds },
|
|
212
|
+
});
|
|
213
|
+
return { productIds: res.productIds ?? res.product_ids ?? [] };
|
|
214
|
+
}
|
|
215
|
+
// POST /timeline_comments — add a comment to a resource's timeline.
|
|
216
|
+
createTimelineComment(body) {
|
|
217
|
+
return this.request("/timeline_comments", { method: "POST", body });
|
|
218
|
+
}
|
|
219
|
+
// DELETE /timeline_comments/{id} — remove a timeline comment (204, no body).
|
|
220
|
+
deleteTimelineComment(id) {
|
|
221
|
+
return this.request(`/timeline_comments/${id}`, { method: "DELETE" });
|
|
222
|
+
}
|
|
223
|
+
// GET /custom_field_definitions — list definitions for an owner type.
|
|
224
|
+
// The API requires the snake_case `owner_type` query param.
|
|
225
|
+
listCustomFieldDefinitions(opts) {
|
|
226
|
+
return this.request("/custom_field_definitions", {
|
|
227
|
+
query: { owner_type: opts.ownerType, limit: opts.limit, page: opts.page },
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
// POST /custom_fields — attach a custom field value to an item or rental.
|
|
231
|
+
// Maps the camelCase input to the snake_case body the API expects.
|
|
232
|
+
createCustomField(body) {
|
|
233
|
+
return this.request("/custom_fields", {
|
|
234
|
+
method: "POST",
|
|
235
|
+
body: {
|
|
236
|
+
owner_id: body.ownerId,
|
|
237
|
+
value: body.value,
|
|
238
|
+
definition_id: body.definitionId,
|
|
239
|
+
key: body.key,
|
|
240
|
+
owner_type: body.ownerType,
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
// PUT /custom_fields/{id} — update an existing custom field's value.
|
|
245
|
+
updateCustomField(id, value) {
|
|
246
|
+
return this.request(`/custom_fields/${id}`, { method: "PUT", body: { value } });
|
|
247
|
+
}
|
|
248
|
+
// DELETE /custom_fields/{id} — remove a custom field (204, no body).
|
|
249
|
+
deleteCustomField(id) {
|
|
250
|
+
return this.request(`/custom_fields/${id}`, { method: "DELETE" });
|
|
251
|
+
}
|
|
252
|
+
// GET /custom_field_definitions/{id} — retrieve a single definition.
|
|
253
|
+
getCustomFieldDefinition(id) {
|
|
254
|
+
return this.request(`/custom_field_definitions/${id}`);
|
|
255
|
+
}
|
|
256
|
+
// GET /locations — list all locations (reference data).
|
|
257
|
+
listLocations() {
|
|
258
|
+
return this.request("/locations");
|
|
259
|
+
}
|
|
260
|
+
// GET /conditions — list all conditions (reference data).
|
|
261
|
+
listConditions() {
|
|
262
|
+
return this.request("/conditions");
|
|
263
|
+
}
|
|
264
|
+
// POST /return_orders — start a return (cycle-scoped via rentalId).
|
|
265
|
+
createReturn(body) {
|
|
266
|
+
return this.request("/return_orders", {
|
|
267
|
+
method: "POST",
|
|
268
|
+
body,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
// GET /return_orders — list returns with optional filters.
|
|
272
|
+
listReturns(filters = {}) {
|
|
273
|
+
return this.request("/return_orders", { query: { ...filters } });
|
|
274
|
+
}
|
|
275
|
+
// Follow keyset pagination to gather every matching return. Caps the number
|
|
276
|
+
// of pages as a safety net against an unexpected server response.
|
|
277
|
+
async listAllReturns(filters = {}, maxPages = 1000) {
|
|
278
|
+
const all = [];
|
|
279
|
+
let page = filters.page;
|
|
280
|
+
for (let i = 0; i < maxPages; i++) {
|
|
281
|
+
// Sequential by design: each request needs the previous page's cursor.
|
|
282
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
283
|
+
const res = await this.listReturns({ ...filters, page });
|
|
284
|
+
all.push(...res.data);
|
|
285
|
+
if (!res.nextPage) {
|
|
286
|
+
return { data: all, nextPage: null };
|
|
287
|
+
}
|
|
288
|
+
page = res.nextPage;
|
|
289
|
+
}
|
|
290
|
+
return { data: all, nextPage: null };
|
|
291
|
+
}
|
|
292
|
+
// GET /return_orders/{id} — retrieve a single return.
|
|
293
|
+
getReturn(id, options = {}) {
|
|
294
|
+
return this.request(`/return_orders/${id}`, {
|
|
295
|
+
query: { include: options.include },
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
// PUT /return_orders/{id} — update a return's status and/or line statuses.
|
|
299
|
+
updateReturn(id, body) {
|
|
300
|
+
return this.request(`/return_orders/${id}`, { method: "PUT", body });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export function configDir() {
|
|
5
|
+
const base = process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config");
|
|
6
|
+
return join(base, "supercycle");
|
|
7
|
+
}
|
|
8
|
+
export function configPath() {
|
|
9
|
+
return join(configDir(), "config.json");
|
|
10
|
+
}
|
|
11
|
+
export function loadConfig() {
|
|
12
|
+
try {
|
|
13
|
+
const raw = readFileSync(configPath(), "utf8");
|
|
14
|
+
return JSON.parse(raw);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// Missing or unreadable config is not an error — fall back to defaults.
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function saveConfig(config) {
|
|
22
|
+
mkdirSync(configDir(), { recursive: true });
|
|
23
|
+
// Mode 0600: the API key is a secret, keep it readable only by the owner.
|
|
24
|
+
writeFileSync(configPath(), JSON.stringify(config, null, 2) + "\n", {
|
|
25
|
+
mode: 0o600,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
// Resolve the API token, preferring the environment over the config file.
|
|
29
|
+
export function getToken() {
|
|
30
|
+
const fromEnv = process.env["SUPERCYCLE_API_KEY"];
|
|
31
|
+
if (fromEnv && fromEnv.trim() !== "") {
|
|
32
|
+
return fromEnv.trim();
|
|
33
|
+
}
|
|
34
|
+
return loadConfig().apiKey ?? null;
|
|
35
|
+
}
|
|
36
|
+
export function getBaseUrl() {
|
|
37
|
+
return process.env["SUPERCYCLE_BASE_URL"] ?? loadConfig().baseUrl ?? undefined;
|
|
38
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export const EMPTY = "—";
|
|
2
|
+
// ISO date-time → short, human-readable local string. Null/undefined → em dash.
|
|
3
|
+
export function formatDateTime(value) {
|
|
4
|
+
if (!value)
|
|
5
|
+
return EMPTY;
|
|
6
|
+
const date = new Date(value);
|
|
7
|
+
if (Number.isNaN(date.getTime()))
|
|
8
|
+
return value;
|
|
9
|
+
return date.toLocaleString(undefined, {
|
|
10
|
+
year: "numeric",
|
|
11
|
+
month: "short",
|
|
12
|
+
day: "numeric",
|
|
13
|
+
hour: "2-digit",
|
|
14
|
+
minute: "2-digit",
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
// ISO date-time → short date only.
|
|
18
|
+
export function formatDate(value) {
|
|
19
|
+
if (!value)
|
|
20
|
+
return EMPTY;
|
|
21
|
+
const date = new Date(value);
|
|
22
|
+
if (Number.isNaN(date.getTime()))
|
|
23
|
+
return value;
|
|
24
|
+
return date.toLocaleDateString(undefined, {
|
|
25
|
+
year: "numeric",
|
|
26
|
+
month: "short",
|
|
27
|
+
day: "numeric",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
// snake_case / lowercase enum → Title Case.
|
|
31
|
+
export function titleCase(value) {
|
|
32
|
+
if (!value)
|
|
33
|
+
return EMPTY;
|
|
34
|
+
return value
|
|
35
|
+
.split(/[_\s]+/)
|
|
36
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
37
|
+
.join(" ");
|
|
38
|
+
}
|
|
39
|
+
export function customerName(customer) {
|
|
40
|
+
if (!customer)
|
|
41
|
+
return EMPTY;
|
|
42
|
+
const name = [customer.firstName, customer.lastName].filter(Boolean).join(" ").trim();
|
|
43
|
+
return name || customer.email || EMPTY;
|
|
44
|
+
}
|
|
45
|
+
export function dash(value) {
|
|
46
|
+
if (value === undefined || value === null || value === "")
|
|
47
|
+
return EMPTY;
|
|
48
|
+
return String(value);
|
|
49
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { option } from "pastel";
|
|
2
|
+
import zod from "zod";
|
|
3
|
+
// Options shared by `cycles list` and the read-convenience commands
|
|
4
|
+
// (to-fulfill, to-receive, overdue, today). Returned as a fresh shape each
|
|
5
|
+
// call so each command gets its own zod field instances.
|
|
6
|
+
export function listViewShape() {
|
|
7
|
+
return {
|
|
8
|
+
search: zod
|
|
9
|
+
.string()
|
|
10
|
+
.optional()
|
|
11
|
+
.describe(option({ description: "Filter by title or customer text" })),
|
|
12
|
+
all: zod
|
|
13
|
+
.boolean()
|
|
14
|
+
.default(false)
|
|
15
|
+
.describe(option({ description: "Fetch every page (auto-paginate)" })),
|
|
16
|
+
limit: zod
|
|
17
|
+
.number()
|
|
18
|
+
.min(1)
|
|
19
|
+
.max(100)
|
|
20
|
+
.default(50)
|
|
21
|
+
.describe(option({ description: "Results per page (1–100)" })),
|
|
22
|
+
page: zod
|
|
23
|
+
.string()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe(option({ description: "Pagination cursor (from a previous run)" })),
|
|
26
|
+
json: zod
|
|
27
|
+
.boolean()
|
|
28
|
+
.default(false)
|
|
29
|
+
.describe(option({ description: "Output raw JSON" })),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
// Pull the shared paging/search fields out of a command's parsed options.
|
|
33
|
+
export function viewFilters(options) {
|
|
34
|
+
return {
|
|
35
|
+
search: options.search,
|
|
36
|
+
limit: options.limit,
|
|
37
|
+
page: options.page,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { SupercycleClient } from "./client.js";
|
|
2
|
+
import { getToken, getBaseUrl } from "./config.js";
|
|
3
|
+
export const NO_TOKEN_HINT = "Set an API key with `supercycle login`, or the SUPERCYCLE_API_KEY environment variable.";
|
|
4
|
+
// Build a client from the resolved token, or return an error if none is set.
|
|
5
|
+
export function resolveClient() {
|
|
6
|
+
const token = getToken();
|
|
7
|
+
if (!token) {
|
|
8
|
+
return { error: "No Supercycle API key found." };
|
|
9
|
+
}
|
|
10
|
+
return { client: new SupercycleClient({ token, baseUrl: getBaseUrl() }) };
|
|
11
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@supercycle/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command-line interface for the Supercycle Admin API",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"bin": {
|
|
7
|
+
"supercycle": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"ink": "^5.1.0",
|
|
18
|
+
"ink-spinner": "^5.0.0",
|
|
19
|
+
"ink-text-input": "^6.0.0",
|
|
20
|
+
"pastel": "^3.0.0",
|
|
21
|
+
"react": "^18.3.1",
|
|
22
|
+
"zod": "^3.23.8"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^22.10.0",
|
|
26
|
+
"@types/react": "^18.3.12",
|
|
27
|
+
"oxfmt": "^0.53.0",
|
|
28
|
+
"oxlint": "^1.68.0",
|
|
29
|
+
"typescript": "^5.7.2"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc",
|
|
36
|
+
"clean": "rm -rf dist",
|
|
37
|
+
"dev": "tsc --watch",
|
|
38
|
+
"start": "node dist/cli.js",
|
|
39
|
+
"fmt": "oxfmt .",
|
|
40
|
+
"fmt:check": "oxfmt --check .",
|
|
41
|
+
"lint": "oxlint",
|
|
42
|
+
"lint:fix": "oxlint --fix"
|
|
43
|
+
}
|
|
44
|
+
}
|