@chabokan.net/cli 0.8.15 → 0.9.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.
Files changed (54) hide show
  1. package/README.md +520 -66
  2. package/dist/base.d.ts +30 -7
  3. package/dist/base.js +163 -14
  4. package/dist/commands/account/info.d.ts +12 -0
  5. package/dist/commands/account/info.js +40 -0
  6. package/dist/commands/account/list.d.ts +5 -1
  7. package/dist/commands/account/list.js +29 -19
  8. package/dist/commands/account/remove.d.ts +5 -1
  9. package/dist/commands/account/remove.js +23 -12
  10. package/dist/commands/account/use.d.ts +6 -2
  11. package/dist/commands/account/use.js +22 -11
  12. package/dist/commands/cloudserver/create.d.ts +40 -0
  13. package/dist/commands/cloudserver/create.js +242 -0
  14. package/dist/commands/cloudserver/delete.d.ts +14 -0
  15. package/dist/commands/cloudserver/delete.js +65 -0
  16. package/dist/commands/cloudserver/list.d.ts +12 -0
  17. package/dist/commands/cloudserver/list.js +46 -0
  18. package/dist/commands/cloudserver/restart.d.ts +13 -0
  19. package/dist/commands/cloudserver/restart.js +51 -0
  20. package/dist/commands/cloudserver/start.d.ts +13 -0
  21. package/dist/commands/cloudserver/start.js +51 -0
  22. package/dist/commands/cloudserver/stop.d.ts +13 -0
  23. package/dist/commands/cloudserver/stop.js +52 -0
  24. package/dist/commands/deploy.d.ts +13 -3
  25. package/dist/commands/deploy.js +96 -60
  26. package/dist/commands/login.d.ts +8 -4
  27. package/dist/commands/login.js +69 -41
  28. package/dist/commands/service/domain/add.d.ts +15 -0
  29. package/dist/commands/service/domain/add.js +74 -0
  30. package/dist/commands/service/domain/remove.d.ts +15 -0
  31. package/dist/commands/service/domain/remove.js +72 -0
  32. package/dist/commands/service/list.d.ts +5 -1
  33. package/dist/commands/service/list.js +29 -21
  34. package/dist/commands/service/logs.d.ts +6 -2
  35. package/dist/commands/service/logs.js +33 -30
  36. package/dist/commands/service/resize.d.ts +9 -5
  37. package/dist/commands/service/resize.js +73 -69
  38. package/dist/commands/service/restart.d.ts +6 -2
  39. package/dist/commands/service/restart.js +28 -32
  40. package/dist/commands/service/start.d.ts +6 -2
  41. package/dist/commands/service/start.js +29 -31
  42. package/dist/commands/service/stop.d.ts +6 -2
  43. package/dist/commands/service/stop.js +29 -31
  44. package/dist/commands/wallet/list.d.ts +12 -0
  45. package/dist/commands/wallet/list.js +41 -0
  46. package/dist/constants.d.ts +2 -0
  47. package/dist/constants.js +7 -2
  48. package/dist/helper.d.ts +23 -7
  49. package/dist/helper.js +294 -113
  50. package/dist/types.d.ts +28 -8
  51. package/dist/ui.d.ts +23 -0
  52. package/dist/ui.js +70 -0
  53. package/oclif.manifest.json +635 -25
  54. package/package.json +39 -35
package/dist/helper.js CHANGED
@@ -1,14 +1,55 @@
1
- import * as fs from "fs";
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
2
3
  import { GLOBAL_CONF_PATH } from "./constants.js";
3
- import axios from "axios";
4
- import { dirname, join, relative } from "path";
4
+ import axios, { isAxiosError } from "axios";
5
+ import { dirname, join, relative } from "node:path";
5
6
  import boxen from 'boxen';
6
7
  import chalk from 'chalk';
7
8
  import semver from 'semver';
8
- import pkgJson from 'package-json';
9
- import semverDiff from 'semver-diff';
9
+ // The config file holds plain-text API tokens, so it must stay owner-only.
10
+ const CONFIG_FILE_MODE = 0o600;
11
+ const PACKAGE_NAME = "@chabokan.net/cli";
12
+ // The update check runs before every command, so it must never be something the
13
+ // user waits on. It is capped hard, cached, and skippable.
14
+ const UPDATE_CHECK_TIMEOUT_MS = 1500;
15
+ const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
16
+ const UPDATE_CACHE_PATH = join(os.tmpdir(), 'chabok-cli', 'update-check.json');
17
+ // Node surfaces certificate problems as error codes. These are permanent
18
+ // failures: retrying will not help, and they must not be reported as a
19
+ // generic "check your internet connection".
20
+ const TLS_ERROR_CODES = new Set([
21
+ 'CERT_HAS_EXPIRED',
22
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
23
+ 'ERR_TLS_CERT_ALTNAME_INVALID',
24
+ 'SELF_SIGNED_CERT_IN_CHAIN',
25
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
26
+ ]);
27
+ const HTML_ENTITIES = {
28
+ ' ': ' ',
29
+ '‌': '‌',
30
+ '&': '&',
31
+ '&lt;': '<',
32
+ '&gt;': '>',
33
+ '&quot;': '"',
34
+ '&#39;': "'",
35
+ };
36
+ /** Some API error/description text comes wrapped in HTML tags (e.g. `<p>...</p>`). */
37
+ export function stripHtml(text) {
38
+ return text
39
+ .replaceAll(/<[^>]*>/g, '')
40
+ .replaceAll(/&[a-z#0-9]+;/gi, (entity) => HTML_ENTITIES[entity] ?? entity)
41
+ .trim();
42
+ }
43
+ function isTlsError(code) {
44
+ return code !== undefined && TLS_ERROR_CODES.has(code);
45
+ }
46
+ export function isDebug() {
47
+ return process.env.CHABOK_DEBUG === "true";
48
+ }
10
49
  export function isObject(obj) {
11
- return obj != null && typeof obj === 'object' && obj.constructor?.name === "Object";
50
+ // `typeof null === 'object'`, so null needs its own check; undefined is
51
+ // already excluded by the typeof test.
52
+ return obj !== null && typeof obj === 'object' && obj.constructor?.name === "Object";
12
53
  }
13
54
  export function isEmptyObject(obj) {
14
55
  return obj !== null && typeof obj === 'object' && obj.constructor === Object && Object.keys(obj).length === 0;
@@ -22,7 +63,7 @@ export function read_config_file() {
22
63
  if (!fs.existsSync(GLOBAL_CONF_PATH)) {
23
64
  return config_json;
24
65
  }
25
- const fileContent = fs.readFileSync(GLOBAL_CONF_PATH).toString('utf-8');
66
+ const fileContent = fs.readFileSync(GLOBAL_CONF_PATH).toString('utf8');
26
67
  if (!fileContent.trim()) {
27
68
  return config_json;
28
69
  }
@@ -35,57 +76,99 @@ export function read_config_file() {
35
76
  }
36
77
  }
37
78
  catch (error) {
38
- if (process.env.CHABOK_DEBUG === "true") {
79
+ if (isDebug()) {
39
80
  console.error(`Error reading config file (${GLOBAL_CONF_PATH}):`, error);
40
81
  }
41
82
  // File doesn't exist or invalid JSON, return default
42
83
  }
43
84
  return config_json;
44
85
  }
45
- export async function get_all_services(filter = {}, axiosConfig) {
46
- const all_services = [];
86
+ export function write_config_file(config_json) {
87
+ // `mode` only applies when the file is created, so tighten it explicitly
88
+ // afterwards for configs written by older versions of the CLI.
89
+ fs.writeFileSync(GLOBAL_CONF_PATH, JSON.stringify(config_json, null, 2), {
90
+ mode: CONFIG_FILE_MODE,
91
+ });
47
92
  try {
48
- let url_filter = "";
49
- Object.keys(filter).forEach((item, index) => {
50
- const key = item;
51
- const value = filter[item];
52
- if (index === 0) {
53
- url_filter += `?${key}=${encodeURIComponent(value)}`;
93
+ fs.chmodSync(GLOBAL_CONF_PATH, CONFIG_FILE_MODE);
94
+ }
95
+ catch (error) {
96
+ // chmod is unsupported on some filesystems (e.g. Windows); not fatal.
97
+ if (isDebug()) {
98
+ console.error(`Could not restrict permissions on ${GLOBAL_CONF_PATH}:`, error);
99
+ }
100
+ }
101
+ }
102
+ /**
103
+ * Retries a request that failed before the server produced a response.
104
+ * Only safe for idempotent (GET) calls: a response-less failure still might
105
+ * mean a non-idempotent request was applied server-side.
106
+ */
107
+ async function getWithRetry(endpoint, axiosConfig, retries = 2) {
108
+ let lastError;
109
+ // Attempts are inherently sequential: each one waits for the previous
110
+ // failure and its backoff, so awaiting inside the loop is the point.
111
+ /* eslint-disable no-await-in-loop */
112
+ for (let attempt = 0; attempt <= retries; attempt++) {
113
+ try {
114
+ const { data } = await axios.get(endpoint, axiosConfig);
115
+ return data;
116
+ }
117
+ catch (error) {
118
+ lastError = error;
119
+ const isTransient = isAxiosError(error) && !error.response && !isTlsError(error.code);
120
+ if (!isTransient || attempt === retries) {
121
+ throw error;
54
122
  }
55
- else {
56
- url_filter += `&${key}=${encodeURIComponent(value)}`;
123
+ if (isDebug()) {
124
+ console.error(`Request to ${endpoint} failed (${error.code}), retrying...`);
57
125
  }
58
- });
59
- const endpoint = "services/" + url_filter;
60
- const { data } = await axios.get(endpoint, axiosConfig);
61
- if (data.services && Array.isArray(data.services)) {
62
- data.services.forEach((item) => {
63
- all_services.push({
64
- value: item.main_name,
65
- name: `${item.main_name} (${item.platform.name})`
66
- });
126
+ await new Promise(resolve => {
127
+ setTimeout(resolve, 500 * (attempt + 1));
67
128
  });
68
129
  }
69
130
  }
70
- catch (error) {
71
- const errorInfo = handleApiError(error, 'Failed to fetch services list.', {
72
- endpoint: 'services/',
73
- operation: 'Fetch services'
131
+ /* eslint-enable no-await-in-loop */
132
+ throw lastError;
133
+ }
134
+ /**
135
+ * Fetches the account's services. Throws on failure so callers can surface a
136
+ * real error instead of an empty list that reads as "you have no services".
137
+ */
138
+ export async function get_all_services(filter = {}, axiosConfig) {
139
+ const all_services = [];
140
+ const query = new URLSearchParams(filter).toString();
141
+ const endpoint = `services/${query ? `?${query}` : ""}`;
142
+ const data = await getWithRetry(endpoint, axiosConfig);
143
+ if (data.services && Array.isArray(data.services)) {
144
+ data.services.forEach((item) => {
145
+ all_services.push({
146
+ value: item.main_name,
147
+ name: `${item.main_name} (${item.platform.name})`,
148
+ platform: item.platform.name
149
+ });
74
150
  });
75
- if (process.env.CHABOK_DEBUG === "true") {
76
- console.error('Error fetching services:', errorInfo);
77
- if (axios.isAxiosError(error)) {
78
- console.error('Full error:', error);
79
- }
80
- }
81
- else {
82
- // Only log critical errors in non-debug mode
83
- if (errorInfo.status && errorInfo.status >= 500) {
84
- console.error(`Error fetching services: ${errorInfo.message}`);
151
+ }
152
+ return all_services;
153
+ }
154
+ /**
155
+ * Pulls a list of records out of an API response whose exact wrapper key
156
+ * isn't guaranteed (some endpoints return a bare array, others wrap it under
157
+ * `data`, `results`, or a resource-named key).
158
+ */
159
+ export function extractRecords(data, wrapperKeys = []) {
160
+ if (Array.isArray(data)) {
161
+ return data.filter((item) => isObject(item));
162
+ }
163
+ if (isObject(data)) {
164
+ for (const key of wrapperKeys) {
165
+ const wrapped = data[key];
166
+ if (Array.isArray(wrapped)) {
167
+ return wrapped.filter((item) => isObject(item));
85
168
  }
86
169
  }
87
170
  }
88
- return all_services;
171
+ return [];
89
172
  }
90
173
  export function trimLines(lines) {
91
174
  return lines.reduce((prev, line) => {
@@ -100,13 +183,13 @@ export const loadIgnoreFile = (ignoreInstance, ignoreFilePath, projectPath) => {
100
183
  const relativeToProjectPath = patterns.map((pattern) => {
101
184
  const dir = dirname(ignoreFilePath);
102
185
  if (pattern.startsWith('!')) {
103
- const absolutePrefix = pattern.substr(1).startsWith('/') ? '/' : '';
104
- return '!' + absolutePrefix + relative(projectPath, join(dir, pattern.substr(1)));
186
+ const absolutePrefix = pattern.slice(1).startsWith('/') ? '/' : '';
187
+ return '!' + absolutePrefix + relative(projectPath, join(dir, pattern.slice(1)));
105
188
  }
106
189
  const absolutePrefix = pattern.startsWith('/') ? '/' : '';
107
190
  return absolutePrefix + relative(projectPath, join(dir, pattern));
108
191
  });
109
- const linuxify = relativeToProjectPath.map(p => p.replace(/\\/g, '/'));
192
+ const linuxify = relativeToProjectPath.map(p => p.replaceAll('\\', '/'));
110
193
  ignoreInstance.add(linuxify);
111
194
  };
112
195
  export function addIgnorePatterns(ignoreInstance, projectPath, dir) {
@@ -123,34 +206,101 @@ export function addIgnorePatterns(ignoreInstance, projectPath, dir) {
123
206
  loadIgnoreFile(ignoreInstance, gitignorePath, projectPath);
124
207
  }
125
208
  }
209
+ /** True when the user has opted out of update checks, or we are in CI. */
210
+ function updateCheckDisabled() {
211
+ return Boolean(process.env.CHABOK_NO_UPDATE_CHECK ||
212
+ process.env.NO_UPDATE_NOTIFIER ||
213
+ process.env.CI);
214
+ }
215
+ function readUpdateCache() {
216
+ try {
217
+ const raw = fs.readFileSync(UPDATE_CACHE_PATH).toString('utf8');
218
+ const parsed = JSON.parse(raw);
219
+ if (typeof parsed?.checkedAt === 'number') {
220
+ return parsed;
221
+ }
222
+ }
223
+ catch {
224
+ // No cache yet, or it is unreadable — treat as "never checked".
225
+ }
226
+ return undefined;
227
+ }
228
+ function writeUpdateCache(cache) {
229
+ try {
230
+ fs.mkdirSync(dirname(UPDATE_CACHE_PATH), { recursive: true });
231
+ fs.writeFileSync(UPDATE_CACHE_PATH, JSON.stringify(cache));
232
+ }
233
+ catch (error) {
234
+ // A cache we cannot write just means we check again next time.
235
+ if (isDebug()) {
236
+ console.error('Could not write the update-check cache:', error);
237
+ }
238
+ }
239
+ }
240
+ /**
241
+ * Asks the npm registry for the latest published version, giving up after
242
+ * `timeoutMs`. axios aborts on timeout rather than leaving the socket
243
+ * dangling, so a slow network cannot hold the process open after the command
244
+ * has finished.
245
+ */
246
+ async function fetchLatestVersion(timeoutMs) {
247
+ const registry = (process.env.CHABOK_NPM_REGISTRY || 'https://registry.npmjs.org').replace(/\/+$/, '');
248
+ const url = `${registry}/${PACKAGE_NAME.replace('/', '%2f')}/latest`;
249
+ const { data } = await axios.get(url, {
250
+ timeout: timeoutMs,
251
+ headers: { accept: 'application/json' },
252
+ // This call is deliberately independent of the CLI's API client: no auth
253
+ // header, no baseURL, no proxy-agent inherited from the command.
254
+ transitional: { clarifyTimeoutError: true },
255
+ });
256
+ return data.version;
257
+ }
258
+ /**
259
+ * Tells the user about a newer release. This runs ahead of every command, so it
260
+ * is deliberately defensive: it is skippable, it only hits the network once a
261
+ * day, it gives up after 1.5s, and any failure is silent. A user with no
262
+ * internet should not notice that this function exists.
263
+ */
126
264
  export const checkUpdate = async (version) => {
127
- const name = "@chabokan.net/cli";
265
+ if (updateCheckDisabled()) {
266
+ return;
267
+ }
128
268
  try {
129
- const { version: latestVersion } = await pkgJson(name);
130
- // check if local package version is less than the remote version
131
- const updateAvailable = semver.lt(version, latestVersion);
132
- if (updateAvailable) {
133
- let updateType = '';
134
- // check the type of version difference which is usually patch, minor, major etc.
135
- const verDiff = semverDiff(version, latestVersion);
136
- if (verDiff) {
137
- updateType = verDiff;
269
+ const cache = readUpdateCache();
270
+ const cacheIsFresh = cache && Date.now() - cache.checkedAt < UPDATE_CHECK_INTERVAL_MS;
271
+ let latestVersion = cacheIsFresh ? cache.latestVersion : undefined;
272
+ if (!cacheIsFresh) {
273
+ try {
274
+ latestVersion = await fetchLatestVersion(UPDATE_CHECK_TIMEOUT_MS);
138
275
  }
139
- const msg = {
140
- updateAvailable: `${updateType} update available ${chalk.dim(version)} ${chalk.green(latestVersion)}`,
141
- runUpdate: `Run ${chalk.cyan(`npm i -g ${name}`)} to update`,
142
- };
143
- // notify the user about the available update
144
- console.log(boxen(`${msg.updateAvailable}\n${msg.runUpdate}`, {
145
- margin: 1,
146
- padding: 1,
147
- align: 'center',
148
- }));
276
+ catch (error) {
277
+ // A failed lookup must still be recorded below, otherwise every command
278
+ // run offline pays the timeout again.
279
+ if (isDebug()) {
280
+ console.error('Update check request failed:', error);
281
+ }
282
+ }
283
+ writeUpdateCache({ checkedAt: Date.now(), latestVersion });
284
+ }
285
+ if (!latestVersion || !semver.valid(latestVersion) || !semver.lt(version, latestVersion)) {
286
+ return;
149
287
  }
288
+ const updateType = semver.diff(version, latestVersion) ?? '';
289
+ const lines = [
290
+ `${updateType} update available ${chalk.dim(version)} → ${chalk.green(latestVersion)}`,
291
+ `Run ${chalk.cyan(`npm i -g ${PACKAGE_NAME}`)} to update`,
292
+ ];
293
+ // stderr, not stdout: the banner must never end up inside piped output
294
+ // such as `chabok service logs -s app > app.log`.
295
+ process.stderr.write(boxen(lines.join('\n'), {
296
+ margin: 1,
297
+ padding: 1,
298
+ align: 'center',
299
+ }) + '\n');
150
300
  }
151
301
  catch (error) {
152
- // Silently fail if update check fails (network issues, etc.)
153
- if (process.env.CHABOK_DEBUG === "true") {
302
+ // Never let the update check break, delay or noisily fail a real command.
303
+ if (isDebug()) {
154
304
  console.error('Failed to check for updates:', error);
155
305
  }
156
306
  }
@@ -162,7 +312,13 @@ export function handleApiError(error, defaultMessage, context) {
162
312
  if (context?.endpoint) {
163
313
  errorInfo.endpoint = context.endpoint;
164
314
  }
165
- if (axios.isAxiosError(error)) {
315
+ if (context?.serviceName) {
316
+ errorInfo.serviceName = context.serviceName;
317
+ }
318
+ if (context?.operation) {
319
+ errorInfo.operation = context.operation;
320
+ }
321
+ if (isAxiosError(error)) {
166
322
  // Extract endpoint from error config
167
323
  if (error.config?.url) {
168
324
  errorInfo.endpoint = error.config.url;
@@ -171,8 +327,8 @@ export function handleApiError(error, defaultMessage, context) {
171
327
  errorInfo.endpoint = `${error.config.baseURL}${errorInfo.endpoint || ''}`;
172
328
  }
173
329
  if (error.response) {
174
- const status = error.response.status;
175
- const data = error.response.data;
330
+ const { status } = error.response;
331
+ const { data } = error.response;
176
332
  errorInfo.status = status;
177
333
  // Build detailed error message based on status code
178
334
  let message = defaultMessage;
@@ -218,19 +374,29 @@ export function handleApiError(error, defaultMessage, context) {
218
374
  default:
219
375
  message = defaultMessage;
220
376
  }
221
- // Try to extract detailed error message from response
377
+ // Try to extract detailed error message from response. Some endpoints
378
+ // (e.g. cloudservers/create/) reply with a `messages` (plural) key
379
+ // instead of `message`, and wrap the text in HTML tags.
222
380
  if (typeof data === 'object' && data !== null) {
223
381
  if ('message' in data && typeof data.message === 'string') {
224
- errorInfo.details = data.message;
225
- message = `${message} ${data.message}`;
382
+ errorInfo.details = stripHtml(data.message);
383
+ message = `${message} ${errorInfo.details}`;
384
+ }
385
+ else if ('messages' in data && typeof data.messages === 'string') {
386
+ errorInfo.details = stripHtml(data.messages);
387
+ message = `${message} ${errorInfo.details}`;
388
+ }
389
+ else if ('messages' in data && Array.isArray(data.messages)) {
390
+ errorInfo.details = data.messages.map((m) => stripHtml(String(m))).join(' ');
391
+ message = `${message} ${errorInfo.details}`;
226
392
  }
227
393
  else if ('error' in data && typeof data.error === 'string') {
228
- errorInfo.details = data.error;
229
- message = `${message} ${data.error}`;
394
+ errorInfo.details = stripHtml(data.error);
395
+ message = `${message} ${errorInfo.details}`;
230
396
  }
231
397
  else if ('detail' in data && typeof data.detail === 'string') {
232
- errorInfo.details = data.detail;
233
- message = `${message} ${data.detail}`;
398
+ errorInfo.details = stripHtml(data.detail);
399
+ message = `${message} ${errorInfo.details}`;
234
400
  }
235
401
  else if (Array.isArray(data) && data.length > 0) {
236
402
  errorInfo.details = JSON.stringify(data);
@@ -241,29 +407,41 @@ export function handleApiError(error, defaultMessage, context) {
241
407
  }
242
408
  }
243
409
  else if (typeof data === 'string') {
244
- errorInfo.details = data;
245
- message = `${message} ${data}`;
410
+ errorInfo.details = stripHtml(data);
411
+ message = `${message} ${errorInfo.details}`;
246
412
  }
247
413
  errorInfo.message = message;
248
414
  errorInfo.code = error.code || `HTTP_${status}`;
249
415
  }
250
416
  else if (error.request) {
251
417
  // Request was made but no response received
252
- if (error.code === 'ECONNREFUSED') {
253
- errorInfo.message = "Connection refused: Unable to connect to the server. Please check your internet connection and try again.";
254
- errorInfo.code = 'ECONNREFUSED';
255
- }
256
- else if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
257
- errorInfo.message = "Request timeout: The server took too long to respond. Please check your connection and try again.";
418
+ if (isTlsError(error.code)) {
419
+ errorInfo.message = `TLS error (${error.code}): the server's certificate could not be verified. If you are intentionally targeting a server with a self-signed certificate, set CHABOK_INSECURE_TLS=true.`;
258
420
  errorInfo.code = error.code;
259
421
  }
260
- else if (error.code === 'ENOTFOUND') {
261
- errorInfo.message = "DNS error: Unable to resolve server address. Please check your internet connection.";
262
- errorInfo.code = 'ENOTFOUND';
263
- }
264
422
  else {
265
- errorInfo.message = "Network error: Unable to reach the server. Please check your internet connection and try again.";
266
- errorInfo.code = error.code || 'NETWORK_ERROR';
423
+ switch (error.code) {
424
+ case 'ECONNABORTED':
425
+ case 'ETIMEDOUT': {
426
+ errorInfo.message = "Request timeout: The server took too long to respond. Please check your connection and try again.";
427
+ errorInfo.code = error.code;
428
+ break;
429
+ }
430
+ case 'ECONNREFUSED': {
431
+ errorInfo.message = "Connection refused: Unable to connect to the server. Please check your internet connection and try again.";
432
+ errorInfo.code = 'ECONNREFUSED';
433
+ break;
434
+ }
435
+ case 'ENOTFOUND': {
436
+ errorInfo.message = "DNS error: Unable to resolve server address. Please check your internet connection.";
437
+ errorInfo.code = 'ENOTFOUND';
438
+ break;
439
+ }
440
+ default: {
441
+ errorInfo.message = "Network error: Unable to reach the server. Please check your internet connection and try again.";
442
+ errorInfo.code = error.code || 'NETWORK_ERROR';
443
+ }
444
+ }
267
445
  }
268
446
  }
269
447
  else {
@@ -280,30 +458,33 @@ export function handleApiError(error, defaultMessage, context) {
280
458
  else if (typeof error === 'string') {
281
459
  errorInfo.message = error;
282
460
  }
283
- // Add context information to message if available
284
- if (context?.serviceName && !errorInfo.message.includes(context.serviceName)) {
285
- errorInfo.message = `[Service: ${context.serviceName}] ${errorInfo.message}`;
286
- }
287
- if (context?.operation && !errorInfo.message.includes(context.operation)) {
288
- errorInfo.message = `[Operation: ${context.operation}] ${errorInfo.message}`;
289
- }
461
+ // Context is deliberately not prefixed onto the message: callers already
462
+ // mention the service they are acting on, and the status-specific messages
463
+ // above name it too. It is kept on `errorInfo` for the debug output.
290
464
  return errorInfo;
291
465
  }
292
466
  export function logErrorDetails(errorInfo, command) {
293
- if (process.env.CHABOK_DEBUG === "true") {
294
- command.log(chalk.dim('--- Error Details ---'));
295
- if (errorInfo.status) {
296
- command.log(chalk.dim(`Status Code: ${errorInfo.status}`));
297
- }
298
- if (errorInfo.endpoint) {
299
- command.log(chalk.dim(`Endpoint: ${errorInfo.endpoint}`));
300
- }
301
- if (errorInfo.code) {
302
- command.log(chalk.dim(`Error Code: ${errorInfo.code}`));
303
- }
304
- if (errorInfo.details) {
305
- command.log(chalk.dim(`Details: ${errorInfo.details}`));
306
- }
307
- command.log(chalk.dim('--- End Error Details ---'));
467
+ if (!isDebug()) {
468
+ return;
469
+ }
470
+ command.log(chalk.dim('--- Error Details ---'));
471
+ if (errorInfo.operation) {
472
+ command.log(chalk.dim(`Operation: ${errorInfo.operation}`));
473
+ }
474
+ if (errorInfo.serviceName) {
475
+ command.log(chalk.dim(`Service: ${errorInfo.serviceName}`));
476
+ }
477
+ if (errorInfo.status) {
478
+ command.log(chalk.dim(`Status Code: ${errorInfo.status}`));
479
+ }
480
+ if (errorInfo.endpoint) {
481
+ command.log(chalk.dim(`Endpoint: ${errorInfo.endpoint}`));
482
+ }
483
+ if (errorInfo.code) {
484
+ command.log(chalk.dim(`Error Code: ${errorInfo.code}`));
485
+ }
486
+ if (errorInfo.details) {
487
+ command.log(chalk.dim(`Details: ${errorInfo.details}`));
308
488
  }
489
+ command.log(chalk.dim('--- End Error Details ---'));
309
490
  }
package/dist/types.d.ts CHANGED
@@ -5,9 +5,15 @@ export interface ConfigFile {
5
5
  export interface UserConfig {
6
6
  token: string;
7
7
  }
8
+ /**
9
+ * A service as offered to the user. `value`/`name` are the shape inquirer
10
+ * expects for a choice; `platform` is kept separately so tables do not have to
11
+ * parse it back out of the label.
12
+ */
8
13
  export interface Service {
9
14
  value: string;
10
15
  name: string;
16
+ platform: string;
11
17
  }
12
18
  export interface ApiResponse<T = unknown> {
13
19
  success: boolean;
@@ -39,14 +45,10 @@ export interface ChabokFile {
39
45
  service?: string;
40
46
  [key: string]: unknown;
41
47
  }
42
- export interface AxiosErrorResponse {
43
- response?: {
44
- status: number;
45
- data?: unknown;
46
- [key: string]: unknown;
47
- };
48
- data?: unknown;
49
- message?: string;
48
+ export interface ErrorContext {
49
+ endpoint?: string;
50
+ serviceName?: string;
51
+ operation?: string;
50
52
  }
51
53
  export interface ErrorInfo {
52
54
  message: string;
@@ -54,4 +56,22 @@ export interface ErrorInfo {
54
56
  endpoint?: string;
55
57
  details?: string;
56
58
  code?: string;
59
+ serviceName?: string;
60
+ operation?: string;
61
+ }
62
+ export interface DomainResponse extends ApiResponse {
63
+ domain_name?: string;
64
+ }
65
+ /**
66
+ * A resolved cloud server. `id` is what the API needs in request bodies;
67
+ * `hostname` is the only part ever shown to the user.
68
+ */
69
+ export interface CloudServerRef {
70
+ id: string;
71
+ hostname: string;
72
+ }
73
+ /** A resolved wallet. `id` is what the API needs; `name` is shown to the user. */
74
+ export interface WalletRef {
75
+ id: string;
76
+ name: string;
57
77
  }
package/dist/ui.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ export declare function success(message: string): string;
2
+ export declare function error(message: string): string;
3
+ export declare function warning(message: string): string;
4
+ export declare function info(message: string): string;
5
+ /** A dimmed follow-up line telling the user what to do next. */
6
+ export declare function hint(message: string): string;
7
+ /** Highlights a command the user is meant to run. */
8
+ export declare function cmd(command: string): string;
9
+ /** Highlights a value the user supplied or chose, such as a service name. */
10
+ export declare function value(text: string): string;
11
+ /**
12
+ * Renders an array of API records as a table without assuming which fields
13
+ * it contains — the columns are whichever keys are present on the first row.
14
+ * Used for endpoints whose response shape isn't fixed enough to hard-code a
15
+ * column list against.
16
+ */
17
+ export declare function printRecords(records: Record<string, unknown>[]): void;
18
+ /**
19
+ * Renders a single API record as Field/Value rows. Preferred over
20
+ * `printRecords` for one object with many fields, which would otherwise
21
+ * produce one very wide, mostly-empty table.
22
+ */
23
+ export declare function printRecord(record: Record<string, unknown>): void;