@youcan/cli-kit 2.4.0 → 2.4.3

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.
@@ -29,6 +29,7 @@ export declare function archived(path: string, name: string, glob?: string): Pro
29
29
  export declare function unlink(path: string): Promise<void>;
30
30
  export declare function readdir(path: string): Promise<string[]>;
31
31
  export declare function stat(path: string): Promise<Stats>;
32
+ export declare function chmod(path: string, mode: number): Promise<void>;
32
33
  export declare const watch: typeof import("chokidar").watch;
33
34
  export declare function decompressGzip(file: string, destination: string, mode?: number): Promise<void>;
34
35
  export declare function extractTar(file: string, cwd: string, mode: number): Promise<void>;
@@ -121,6 +121,9 @@ async function readdir(path) {
121
121
  async function stat(path) {
122
122
  return await FilesystemPromises.stat(path);
123
123
  }
124
+ async function chmod(path, mode) {
125
+ return await FilesystemPromises.chmod(path, mode);
126
+ }
124
127
  const watch = chokidar.watch;
125
128
  async function decompressGzip(file, destination, mode = 0o755) {
126
129
  const unzip = createGunzip();
@@ -132,4 +135,4 @@ async function extractTar(file, cwd, mode) {
132
135
  await tar.extract({ cwd, file, mode });
133
136
  }
134
137
 
135
- export { archived, decompressGzip, exists, extractTar, glob, isDirectory, isExecutable, mkdir, move, readFile, readJsonFile, readdir, rm, stat, tapIntoTmp, unlink, watch, writeFile, writeJsonFile };
138
+ export { archived, chmod, decompressGzip, exists, extractTar, glob, isDirectory, isExecutable, mkdir, move, readFile, readJsonFile, readdir, rm, stat, tapIntoTmp, unlink, watch, writeFile, writeJsonFile };
@@ -73,7 +73,7 @@ async function authorize(command, state = randomHex(30)) {
73
73
  code_challenge: challenge,
74
74
  code_challenge_method: 'S256',
75
75
  };
76
- await command.output.anykey('Press any key to open the login page on your browser..');
76
+ await command.output.anykey('Press any key to open the login page on your browser');
77
77
  const url = `http://${AUTHORIZATION_URL}/admin/oauth/authorize?${new URLSearchParams(params).toString()}`;
78
78
  open(url);
79
79
  const result = await listen(command, LS_HOST, LS_PORT, url);
@@ -1,5 +1,4 @@
1
1
  /// <reference types="node" />
2
- /// <reference types="node" />
3
2
  import type { ExecaError } from 'execa';
4
3
  import type { Readable, Writable } from 'node:stream';
5
4
  export interface ExecOptions {
@@ -3,6 +3,7 @@ import { Writable } from 'node:stream';
3
3
  export interface Interface {
4
4
  run: () => Promise<void>;
5
5
  boot: () => Promise<void>;
6
+ cleanup: () => Promise<void>;
6
7
  }
7
8
  export declare class Logger extends Writable {
8
9
  private readonly type;
@@ -13,4 +14,5 @@ export declare class Logger extends Writable {
13
14
  export declare abstract class Abstract implements Interface {
14
15
  abstract run(): Promise<void>;
15
16
  abstract boot(): Promise<void>;
17
+ cleanup(): Promise<void>;
16
18
  }
@@ -46,6 +46,8 @@ class Logger extends Writable {
46
46
  }
47
47
  }
48
48
  class Abstract {
49
+ async cleanup() {
50
+ }
49
51
  }
50
52
 
51
53
  export { Abstract, Logger };
@@ -3,7 +3,7 @@ export declare class Cloudflared {
3
3
  private readonly system;
4
4
  private readonly output;
5
5
  constructor();
6
- tunnel(port: number, host?: string): Promise<void>;
6
+ tunnel(port: number, host?: string, signal?: AbortSignal): Promise<void>;
7
7
  private install;
8
8
  private composeTunnelingCommand;
9
9
  private exec;
@@ -8,7 +8,7 @@ import 'change-case';
8
8
  import '../node/cli.js';
9
9
  import 'conf';
10
10
  import 'env-paths';
11
- import { isExecutable, tapIntoTmp, mkdir, decompressGzip, extractTar, move } from '../node/filesystem.js';
11
+ import { isExecutable, tapIntoTmp, mkdir, decompressGzip, extractTar, move, chmod } from '../node/filesystem.js';
12
12
  import 'formdata-node';
13
13
  import 'formdata-node/file-from-path';
14
14
  import 'simple-git';
@@ -85,6 +85,7 @@ async function installForLinux(url, destination) {
85
85
  const parentDir = dirname(destination);
86
86
  await mkdir(parentDir);
87
87
  await downloadFromRelease(url, destination);
88
+ await chmod(destination, 0o755);
88
89
  }
89
90
  async function installForWindows(url, destination) {
90
91
  const parentDir = dirname(destination);
@@ -146,6 +147,9 @@ class OutputStream extends Writable {
146
147
  clearBuffer() {
147
148
  this.buffer = '';
148
149
  }
150
+ getBuffer() {
151
+ return this.buffer;
152
+ }
149
153
  }
150
154
  class Cloudflared {
151
155
  bin;
@@ -163,10 +167,10 @@ class Cloudflared {
163
167
  this.bin = resolveBinaryPath(platform);
164
168
  this.system = { platform, arch };
165
169
  }
166
- async tunnel(port, host = 'localhost') {
170
+ async tunnel(port, host = 'localhost', signal) {
167
171
  await this.install();
168
172
  const { bin, args } = this.composeTunnelingCommand(port, host);
169
- this.exec(bin, args);
173
+ this.exec(bin, args, 3, signal);
170
174
  }
171
175
  async install() {
172
176
  if (await isExecutable(this.bin)) {
@@ -174,6 +178,9 @@ class Cloudflared {
174
178
  }
175
179
  const downloadUrl = composeDownloadUrl(this.system.platform, this.system.arch);
176
180
  await install(this.system.platform, downloadUrl, this.bin);
181
+ if (!(await isExecutable(this.bin))) {
182
+ throw new Error(`Failed to install executable cloudflared binary at ${this.bin}. Check file permissions and platform compatibility.`);
183
+ }
177
184
  }
178
185
  composeTunnelingCommand(port, host = 'localhost') {
179
186
  return {
@@ -181,19 +188,25 @@ class Cloudflared {
181
188
  args: ['tunnel', `--url=${host}:${port}`, '--no-autoupdate'],
182
189
  };
183
190
  }
184
- async exec(bin, args, maxRetries = 3) {
191
+ async exec(bin, args, maxRetries = 3, signal) {
185
192
  if (this.getUrl()) {
186
193
  return;
187
194
  }
188
195
  if (maxRetries === 0) {
189
- throw new Error(this.output.extractError() ?? 'cloudflared failed for unknown reason');
196
+ const extractedError = this.output.extractError();
197
+ const errorMessage = extractedError
198
+ ? `cloudflared failed: ${extractedError}`
199
+ : `cloudflared failed for unknown reason. Binary: ${bin}, Args: ${args.join(' ')}, Buffer: ${this.output.getBuffer()}`;
200
+ throw new Error(errorMessage);
190
201
  }
191
202
  this.output.clearBuffer();
192
203
  await exec(bin, args, {
193
204
  // Weird choice of cloudflared to write to stderr.
194
205
  stderr: this.output,
195
- errorHandler: async () => {
196
- await this.exec(bin, args, maxRetries - 1);
206
+ signal,
207
+ errorHandler: async (error) => {
208
+ console.error(`cloudflared execution error (retries left: ${maxRetries - 1}):`, error);
209
+ await this.exec(bin, args, maxRetries - 1, signal);
197
210
  },
198
211
  });
199
212
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,114 @@
1
+ import { vi, describe, beforeEach, afterAll, it, expect } from 'vitest';
2
+ import 'change-case';
3
+ import '../node/cli.js';
4
+ import 'conf';
5
+ import 'env-paths';
6
+ import { isExecutable } from '../node/filesystem.js';
7
+ import 'formdata-node';
8
+ import 'formdata-node/file-from-path';
9
+ import 'simple-git';
10
+ import { exec } from '../node/system.js';
11
+ import 'node-fetch';
12
+ import 'ramda';
13
+ import 'kleur';
14
+ import 'dayjs';
15
+ import { Cloudflared } from './cloudflared.js';
16
+ import '../ui/components/DevOutput.js';
17
+ import 'react';
18
+ import 'ink';
19
+
20
+ vi.mock('..', () => ({
21
+ System: {
22
+ exec: vi.fn(),
23
+ },
24
+ Filesystem: {
25
+ isExecutable: vi.fn(),
26
+ },
27
+ Path: {
28
+ join: vi.fn().mockReturnValue('/mock/path/cloudflared'),
29
+ },
30
+ }));
31
+ const originalPlatform = process.platform;
32
+ const originalArch = process.arch;
33
+ describe('cloudflared', () => {
34
+ let cloudflared;
35
+ let mockAbortController;
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ Object.defineProperty(process, 'platform', {
39
+ value: 'darwin',
40
+ configurable: true,
41
+ });
42
+ Object.defineProperty(process, 'arch', {
43
+ value: 'arm64',
44
+ configurable: true,
45
+ });
46
+ vi.mocked(isExecutable).mockResolvedValue(true);
47
+ mockAbortController = new AbortController();
48
+ cloudflared = new Cloudflared();
49
+ });
50
+ afterAll(() => {
51
+ Object.defineProperty(process, 'platform', {
52
+ value: originalPlatform,
53
+ configurable: true,
54
+ });
55
+ Object.defineProperty(process, 'arch', {
56
+ value: originalArch,
57
+ configurable: true,
58
+ });
59
+ });
60
+ describe('tunnel', () => {
61
+ it('should pass abort signal to System.exec', async () => {
62
+ vi.mocked(exec).mockResolvedValue();
63
+ await cloudflared.tunnel(3000, 'localhost', mockAbortController.signal);
64
+ expect(exec).toHaveBeenCalledWith(expect.any(String), expect.any(Array), expect.objectContaining({
65
+ signal: mockAbortController.signal,
66
+ }));
67
+ });
68
+ it('should use correct cloudflared arguments', async () => {
69
+ vi.mocked(exec).mockResolvedValue();
70
+ await cloudflared.tunnel(3001, 'localhost', mockAbortController.signal);
71
+ expect(exec).toHaveBeenCalledWith(expect.any(String), ['tunnel', '--url=localhost:3001', '--no-autoupdate'], expect.any(Object));
72
+ });
73
+ it('should work without abort signal', async () => {
74
+ vi.mocked(exec).mockResolvedValue();
75
+ await expect(cloudflared.tunnel(3000)).resolves.toBeUndefined();
76
+ expect(exec).toHaveBeenCalledWith(expect.any(String), expect.any(Array), expect.objectContaining({
77
+ signal: undefined,
78
+ }));
79
+ });
80
+ it('should use default host when not provided', async () => {
81
+ vi.mocked(exec).mockResolvedValue();
82
+ await cloudflared.tunnel(3000, undefined, mockAbortController.signal);
83
+ expect(exec).toHaveBeenCalledWith(expect.any(String), ['tunnel', '--url=localhost:3000', '--no-autoupdate'], expect.any(Object));
84
+ });
85
+ it('should check if cloudflared is executable before running', async () => {
86
+ vi.mocked(exec).mockResolvedValue();
87
+ await cloudflared.tunnel(3000, 'localhost', mockAbortController.signal);
88
+ expect(isExecutable).toHaveBeenCalledWith('/mock/path/cloudflared');
89
+ });
90
+ });
91
+ describe('exec retry logic with signal', () => {
92
+ it('should retry with the same signal on failure', async () => {
93
+ let callCount = 0;
94
+ vi.mocked(exec).mockImplementation(async (bin, args, options) => {
95
+ callCount++;
96
+ if (callCount < 2) {
97
+ if (options?.errorHandler) {
98
+ await options.errorHandler(new Error('Connection failed'));
99
+ }
100
+ }
101
+ });
102
+ await cloudflared.tunnel(3000, 'localhost', mockAbortController.signal);
103
+ expect(exec).toHaveBeenCalledTimes(2);
104
+ expect(exec).toHaveBeenNthCalledWith(1, expect.any(String), expect.any(Array), expect.objectContaining({ signal: mockAbortController.signal }));
105
+ expect(exec).toHaveBeenNthCalledWith(2, expect.any(String), expect.any(Array), expect.objectContaining({ signal: mockAbortController.signal }));
106
+ });
107
+ it('should handle errors without crashing', async () => {
108
+ vi.mocked(exec).mockResolvedValue();
109
+ await expect(cloudflared.tunnel(3000, 'localhost', mockAbortController.signal))
110
+ .resolves
111
+ .toBeUndefined();
112
+ });
113
+ });
114
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@youcan/cli-kit",
3
3
  "type": "module",
4
- "version": "2.4.0",
4
+ "version": "2.4.3",
5
5
  "description": "Utilities for the YouCan CLI",
6
6
  "author": "YouCan <contact@youcan.shop> (https://youcan.shop)",
7
7
  "license": "MIT",
@@ -60,6 +60,8 @@
60
60
  "scripts": {
61
61
  "build": "shx rm -rf dist && tsc --noEmit && rollup --config rollup.config.js",
62
62
  "dev": "shx rm -rf dist && rollup --config rollup.config.js --watch",
63
+ "test": "vitest run",
64
+ "test:watch": "vitest",
63
65
  "release": "pnpm publish --access public",
64
66
  "type-check": "tsc --noEmit"
65
67
  }