@joint/cli 0.1.1 → 0.2.2

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
@@ -6,7 +6,7 @@ Command-line tool for [JointJS](https://jointjs.com).
6
6
 
7
7
  ```bash
8
8
  npx @joint/cli list
9
- npx @joint/cli download scada/js
9
+ npx @joint/cli download kitchen-sink/js
10
10
  ```
11
11
 
12
12
  ## Installation
@@ -19,7 +19,7 @@ Once installed globally, the `joint` command is available:
19
19
 
20
20
  ```bash
21
21
  joint list
22
- joint download scada/js
22
+ joint download kitchen-sink/js
23
23
  ```
24
24
 
25
25
  ## Commands
@@ -37,14 +37,21 @@ joint list
37
37
  Download an example into the current working directory.
38
38
 
39
39
  ```bash
40
- # Downloads into ./scada-js/
41
- joint download scada/js
40
+ # Downloads into ./kitchen-sink-js/
41
+ joint download kitchen-sink/js
42
42
 
43
- # Downloads into ./scada/
44
- joint download scada/js scada
43
+ # Downloads into ./my-app/
44
+ joint download kitchen-sink/js my-app
45
45
 
46
- # Downloads into the current directory
47
- joint download scada/js .
46
+ # Downloads into the current directory (must be empty or use --force)
47
+ joint download kitchen-sink/js .
48
+ ```
49
+
50
+ If the destination directory already exists, use `--force` to overwrite:
51
+
52
+ ```bash
53
+ joint download kitchen-sink/js --force
54
+ joint download kitchen-sink/js . --force
48
55
  ```
49
56
 
50
57
  ## Options
@@ -55,6 +62,7 @@ joint download scada/js .
55
62
  | `--version`, `-v` | Show version number |
56
63
  | `--owner <name>` | GitHub repo owner (default: `clientIO`) |
57
64
  | `--branch <name>` | GitHub repo branch (default: `main`) |
65
+ | `--force` | Overwrite existing files when downloading |
58
66
 
59
67
  ### Working with forks
60
68
 
@@ -62,7 +70,7 @@ Use `--owner` and `--branch` to list and download examples from a fork:
62
70
 
63
71
  ```bash
64
72
  joint list --owner myGitHubUser
65
- joint download scada/js --owner myGitHubUser --branch dev
73
+ joint download kitchen-sink/js --owner myGitHubUser --branch dev
66
74
  ```
67
75
 
68
76
  ## Environment Variables
@@ -0,0 +1,67 @@
1
+ import { describe, it, beforeEach, afterEach, mock } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ const defaultOptions = { owner: 'clientIO', branch: 'main' };
4
+ function mockFetchWithFolders(folders) {
5
+ const tree = folders.flatMap((f) => {
6
+ const top = f.split('/')[0];
7
+ return [
8
+ { path: top, type: 'tree' },
9
+ { path: f, type: 'tree' },
10
+ ];
11
+ });
12
+ globalThis.fetch = mock.fn(async () => ({
13
+ ok: true,
14
+ json: async () => ({ tree }),
15
+ }));
16
+ }
17
+ describe('download command', () => {
18
+ let originalFetch;
19
+ const originalExit = process.exit;
20
+ let exitCode;
21
+ beforeEach(() => {
22
+ originalFetch = globalThis.fetch;
23
+ exitCode = undefined;
24
+ process.exit = mock.fn((code) => {
25
+ exitCode = code;
26
+ throw new Error(`process.exit(${code})`);
27
+ });
28
+ });
29
+ afterEach(() => {
30
+ globalThis.fetch = originalFetch;
31
+ process.exit = originalExit;
32
+ });
33
+ it('exits with error when example not found', async () => {
34
+ mockFetchWithFolders(['scada/js', 'scada/ts']);
35
+ // Dynamic import to get fresh module
36
+ const { download } = await import('../commands/download.js');
37
+ await assert.rejects(() => download('nonexistent/js', undefined, defaultOptions), { message: 'process.exit(1)' });
38
+ assert.equal(exitCode, 1);
39
+ });
40
+ it('exits with error when folder not found in empty repo', async () => {
41
+ globalThis.fetch = mock.fn(async () => ({
42
+ ok: true,
43
+ json: async () => ({ tree: [] }),
44
+ }));
45
+ const { download } = await import('../commands/download.js');
46
+ await assert.rejects(() => download('scada/js', undefined, defaultOptions), { message: 'process.exit(1)' });
47
+ });
48
+ it('computes default directory name from folder path', async () => {
49
+ // We can test the naming logic by checking what happens when the dir already exists
50
+ // If we provide a folder that exists in the list, it will try to create "scada-js"
51
+ // We test this indirectly — the naming logic is: folder.replace(/\//g, '-')
52
+ const folderName = 'scada/js';
53
+ const expected = 'scada-js';
54
+ assert.equal(folderName.replace(/\//g, '-'), expected);
55
+ });
56
+ it('uses custom target name when provided', () => {
57
+ const folderName = 'scada/js';
58
+ const target = 'my-project';
59
+ const dirName = target ?? folderName.replace(/\//g, '-');
60
+ assert.equal(dirName, 'my-project');
61
+ });
62
+ it('handles dot target for current directory', () => {
63
+ const dirName = '.';
64
+ const displayPath = dirName === '.' ? 'current directory' : `./${dirName}`;
65
+ assert.equal(displayPath, 'current directory');
66
+ });
67
+ });
@@ -0,0 +1,92 @@
1
+ import { describe, it, beforeEach, afterEach, mock } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { listDemoFolders } from '../lib/github.js';
4
+ const defaultOptions = { owner: 'clientIO', branch: 'main' };
5
+ describe('listDemoFolders', () => {
6
+ let originalFetch;
7
+ beforeEach(() => {
8
+ originalFetch = globalThis.fetch;
9
+ });
10
+ afterEach(() => {
11
+ globalThis.fetch = originalFetch;
12
+ delete process.env.GITHUB_TOKEN;
13
+ });
14
+ it('returns sorted 2-level deep directories', async () => {
15
+ globalThis.fetch = mock.fn(async () => ({
16
+ ok: true,
17
+ json: async () => ({
18
+ tree: [
19
+ { path: 'scada', type: 'tree' },
20
+ { path: 'scada/js', type: 'tree' },
21
+ { path: 'scada/ts', type: 'tree' },
22
+ { path: 'scada/js/package.json', type: 'blob' },
23
+ { path: 'kitchen-sink', type: 'tree' },
24
+ { path: 'kitchen-sink/js', type: 'tree' },
25
+ ],
26
+ }),
27
+ }));
28
+ const result = await listDemoFolders(defaultOptions);
29
+ assert.deepEqual(result, ['kitchen-sink/js', 'scada/js', 'scada/ts']);
30
+ });
31
+ it('filters out blobs and top-level directories', async () => {
32
+ globalThis.fetch = mock.fn(async () => ({
33
+ ok: true,
34
+ json: async () => ({
35
+ tree: [
36
+ { path: 'README.md', type: 'blob' },
37
+ { path: 'scada', type: 'tree' },
38
+ { path: 'scada/js', type: 'tree' },
39
+ { path: 'scada/js/src', type: 'tree' },
40
+ ],
41
+ }),
42
+ }));
43
+ const result = await listDemoFolders(defaultOptions);
44
+ assert.deepEqual(result, ['scada/js']);
45
+ });
46
+ it('returns empty array when tree is empty', async () => {
47
+ globalThis.fetch = mock.fn(async () => ({
48
+ ok: true,
49
+ json: async () => ({ tree: [] }),
50
+ }));
51
+ const result = await listDemoFolders(defaultOptions);
52
+ assert.deepEqual(result, []);
53
+ });
54
+ it('throws on 404', async () => {
55
+ globalThis.fetch = mock.fn(async () => ({
56
+ ok: false,
57
+ status: 404,
58
+ statusText: 'Not Found',
59
+ }));
60
+ await assert.rejects(() => listDemoFolders(defaultOptions), { message: 'Repository or branch not found. Please verify the --owner and --branch options.' });
61
+ });
62
+ it('throws on other HTTP errors', async () => {
63
+ globalThis.fetch = mock.fn(async () => ({
64
+ ok: false,
65
+ status: 403,
66
+ statusText: 'rate limit exceeded',
67
+ }));
68
+ await assert.rejects(() => listDemoFolders(defaultOptions), { message: 'GitHub API request failed: 403 rate limit exceeded' });
69
+ });
70
+ it('uses correct URL with custom owner and branch', async () => {
71
+ const mockFetch = mock.fn(async () => ({
72
+ ok: true,
73
+ json: async () => ({ tree: [] }),
74
+ }));
75
+ globalThis.fetch = mockFetch;
76
+ await listDemoFolders({ owner: 'myFork', branch: 'dev' });
77
+ const calledUrl = mockFetch.mock.calls[0].arguments[0];
78
+ assert.ok(calledUrl.includes('/myFork/'));
79
+ assert.ok(calledUrl.endsWith('/git/trees/dev?recursive=1'));
80
+ });
81
+ it('includes Authorization header when GITHUB_TOKEN is set', async () => {
82
+ process.env.GITHUB_TOKEN = 'test-token';
83
+ const mockFetch = mock.fn(async () => ({
84
+ ok: true,
85
+ json: async () => ({ tree: [] }),
86
+ }));
87
+ globalThis.fetch = mockFetch;
88
+ await listDemoFolders(defaultOptions);
89
+ const calledHeaders = mockFetch.mock.calls[0].arguments[1];
90
+ assert.equal(calledHeaders.headers['Authorization'], 'Bearer test-token');
91
+ });
92
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, it, beforeEach, afterEach, mock } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { list } from '../commands/list.js';
4
+ const defaultOptions = { owner: 'clientIO', branch: 'main' };
5
+ describe('list command', () => {
6
+ let originalFetch;
7
+ const logOutput = [];
8
+ const originalLog = console.log;
9
+ beforeEach(() => {
10
+ originalFetch = globalThis.fetch;
11
+ logOutput.length = 0;
12
+ console.log = (...args) => {
13
+ logOutput.push(args.join(' '));
14
+ };
15
+ });
16
+ afterEach(() => {
17
+ globalThis.fetch = originalFetch;
18
+ console.log = originalLog;
19
+ });
20
+ it('prints available examples', async () => {
21
+ globalThis.fetch = mock.fn(async () => ({
22
+ ok: true,
23
+ json: async () => ({
24
+ tree: [
25
+ { path: 'scada', type: 'tree' },
26
+ { path: 'scada/js', type: 'tree' },
27
+ { path: 'kitchen-sink', type: 'tree' },
28
+ { path: 'kitchen-sink/ts', type: 'tree' },
29
+ ],
30
+ }),
31
+ }));
32
+ await list(defaultOptions);
33
+ assert.ok(logOutput.some((line) => line.includes('scada/js')));
34
+ assert.ok(logOutput.some((line) => line.includes('kitchen-sink/ts')));
35
+ });
36
+ it('handles empty tree', async () => {
37
+ globalThis.fetch = mock.fn(async () => ({
38
+ ok: true,
39
+ json: async () => ({ tree: [] }),
40
+ }));
41
+ await list(defaultOptions);
42
+ assert.ok(!logOutput.some((line) => line.includes(' - ')));
43
+ });
44
+ it('passes options to the API call', async () => {
45
+ const mockFetch = mock.fn(async () => ({
46
+ ok: true,
47
+ json: async () => ({ tree: [] }),
48
+ }));
49
+ globalThis.fetch = mockFetch;
50
+ await list({ owner: 'myFork', branch: 'dev' });
51
+ const calledUrl = mockFetch.mock.calls[0].arguments[0];
52
+ assert.ok(calledUrl.includes('/myFork/'));
53
+ assert.ok(calledUrl.includes('/dev?'));
54
+ });
55
+ });
package/dist/cli.js CHANGED
@@ -21,20 +21,29 @@ ${logger.bold('Options:')}
21
21
  --version, -v Show version number
22
22
  --owner <name> GitHub repo owner (default: ${DEFAULT_OWNER})
23
23
  --branch <name> GitHub repo branch (default: ${DEFAULT_BRANCH})
24
+ --force Overwrite existing files when downloading
24
25
 
25
26
  ${logger.bold('Environment:')}
26
27
  GITHUB_TOKEN Optional GitHub token to avoid rate limiting
27
28
  `;
28
29
  function getFlag(args, name) {
29
30
  const index = args.indexOf(name);
30
- if (index === -1 || index + 1 >= args.length)
31
+ if (index === -1)
31
32
  return undefined;
32
- return args[index + 1];
33
+ const value = args[index + 1];
34
+ if (index + 1 >= args.length || !value || value.startsWith('--')) {
35
+ logger.error(`Missing value for option "${name}".`);
36
+ process.exit(1);
37
+ }
38
+ return value;
33
39
  }
34
40
  function stripFlags(args) {
35
41
  const result = [];
36
42
  for (let i = 0; i < args.length; i++) {
37
- if (args[i] === '--owner' || args[i] === '--branch') {
43
+ if (args[i] === '--force') {
44
+ continue;
45
+ }
46
+ else if (args[i] === '--owner' || args[i] === '--branch') {
38
47
  i++; // skip the value
39
48
  }
40
49
  else {
@@ -46,16 +55,17 @@ function stripFlags(args) {
46
55
  async function main() {
47
56
  const rawArgs = process.argv.slice(2);
48
57
  if (rawArgs.length === 0 || rawArgs.includes('--help') || rawArgs.includes('-h')) {
49
- console.log(HELP);
58
+ logger.log(HELP);
50
59
  return;
51
60
  }
52
61
  if (rawArgs.includes('--version') || rawArgs.includes('-v')) {
53
- console.log(VERSION);
62
+ logger.log(VERSION);
54
63
  return;
55
64
  }
56
65
  const options = {
57
66
  owner: getFlag(rawArgs, '--owner') ?? DEFAULT_OWNER,
58
67
  branch: getFlag(rawArgs, '--branch') ?? DEFAULT_BRANCH,
68
+ force: rawArgs.includes('--force'),
59
69
  };
60
70
  const args = stripFlags(rawArgs);
61
71
  const command = args[0];
@@ -1,4 +1,4 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, readdirSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
3
  import { listDemoFolders } from '../lib/github.js';
4
4
  import { sparseCheckout } from '../lib/git.js';
@@ -9,11 +9,11 @@ export async function download(folder, target, options) {
9
9
  if (!folders.includes(folder)) {
10
10
  logger.error(`Example "${folder}" not found.`);
11
11
  if (folders.length > 0) {
12
- console.log(`\n${logger.bold('Available examples:')}\n`);
12
+ logger.log(`\n${logger.bold('Available examples:')}\n`);
13
13
  for (const f of folders) {
14
- console.log(` - ${f}`);
14
+ logger.log(` - ${f}`);
15
15
  }
16
- console.log();
16
+ logger.log('');
17
17
  }
18
18
  else {
19
19
  logger.warn('No examples are available yet.');
@@ -22,11 +22,18 @@ export async function download(folder, target, options) {
22
22
  }
23
23
  const dirName = target ?? folder.replace(/\//g, '-');
24
24
  const dest = resolve(process.cwd(), dirName);
25
- if (dirName !== '.' && existsSync(dest)) {
26
- logger.error(`Directory "${dirName}" already exists in the current directory.`);
27
- process.exit(1);
25
+ if (existsSync(dest)) {
26
+ if (dirName !== '.' && !options.force) {
27
+ logger.error(`Directory "${dirName}" already exists. Use --force to overwrite.`);
28
+ process.exit(1);
29
+ }
30
+ if (dirName === '.' && readdirSync(dest).length > 0 && !options.force) {
31
+ logger.error('Current directory is not empty. Use --force to overwrite existing files.');
32
+ process.exit(1);
33
+ }
28
34
  }
29
35
  logger.info(`Downloading "${folder}"...`);
30
36
  await sparseCheckout(folder, dest, options);
31
- logger.success(`\nDone! Example downloaded to ./${dirName}`);
37
+ const displayPath = dirName === '.' ? 'current directory' : `./${dirName}`;
38
+ logger.success(`\nDone! Example downloaded to ${displayPath}`);
32
39
  }
@@ -7,9 +7,9 @@ export async function list(options) {
7
7
  logger.warn('No examples available yet.');
8
8
  return;
9
9
  }
10
- console.log(logger.bold('Available examples:\n'));
10
+ logger.log(logger.bold('Available examples:\n'));
11
11
  for (const folder of folders) {
12
- console.log(` - ${folder}`);
12
+ logger.log(` - ${folder}`);
13
13
  }
14
- console.log();
14
+ logger.log('');
15
15
  }
package/dist/lib/git.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { mkdtemp, rm, cp } from 'node:fs/promises';
2
+ import { mkdtemp, rm, cp, readdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
+ import { existsSync } from 'node:fs';
5
6
  import { getRepoUrl } from '../constants.js';
6
7
  function run(command, args, cwd) {
7
8
  return new Promise((resolve, reject) => {
@@ -21,12 +22,19 @@ export async function sparseCheckout(folder, dest, options) {
21
22
  await run('git', ['init', tmp]);
22
23
  await run('git', ['remote', 'add', 'origin', repoUrl], tmp);
23
24
  await run('git', ['sparse-checkout', 'init', '--cone'], tmp);
24
- await run('git', ['sparse-checkout', 'set', folder], tmp);
25
- await run('git', ['pull', 'origin', options.branch, '--depth=1'], tmp);
25
+ await run('git', ['sparse-checkout', 'set', '--', folder], tmp);
26
+ await run('git', ['pull', 'origin', '--depth=1', '--', options.branch], tmp);
26
27
  const src = join(tmp, folder);
27
- // If dest already exists (e.g. "."), copy contents into it.
28
- // Otherwise, move the folder directly.
29
- await cp(src, dest, { recursive: true });
28
+ if (existsSync(dest)) {
29
+ // Copy each entry individually into the existing directory
30
+ const entries = await readdir(src);
31
+ for (const entry of entries) {
32
+ await cp(join(src, entry), join(dest, entry), { recursive: true });
33
+ }
34
+ }
35
+ else {
36
+ await cp(src, dest, { recursive: true });
37
+ }
30
38
  }
31
39
  finally {
32
40
  await rm(tmp, { recursive: true, force: true });
@@ -12,11 +12,11 @@ function buildHeaders() {
12
12
  }
13
13
  export async function listDemoFolders(options) {
14
14
  const apiUrl = getGitHubApiUrl(options);
15
- const url = `${apiUrl}/git/trees/${options.branch}?recursive=1`;
15
+ const url = `${apiUrl}/git/trees/${encodeURIComponent(options.branch)}?recursive=1`;
16
16
  const response = await fetch(url, { headers: buildHeaders() });
17
17
  if (!response.ok) {
18
18
  if (response.status === 404) {
19
- return [];
19
+ throw new Error('Repository or branch not found. Please verify the --owner and --branch options.');
20
20
  }
21
21
  throw new Error(`GitHub API request failed: ${response.status} ${response.statusText}`);
22
22
  }
@@ -26,7 +26,9 @@ export async function listDemoFolders(options) {
26
26
  }
27
27
  // Return only 2-level deep directories (e.g. "scada/js", "kitchen-sink/ts")
28
28
  return data.tree
29
- .filter((item) => item.type === 'tree' && item.path.split('/').length === 2)
29
+ // Only include items that are directories (type 'tree'),
30
+ // are exactly 2 levels deep, and don't start with a dot (to exclude hidden folders)
31
+ .filter((item) => item.type === 'tree' && item.path.split('/').length === 2 && !item.path.startsWith('.'))
30
32
  .map((item) => item.path)
31
33
  .sort();
32
34
  }
@@ -1,3 +1,4 @@
1
+ /* eslint-disable no-console */
1
2
  const RESET = '\x1b[0m';
2
3
  const RED = '\x1b[31m';
3
4
  const GREEN = '\x1b[32m';
@@ -11,11 +12,14 @@ export function success(msg) {
11
12
  console.log(`${GREEN}${msg}${RESET}`);
12
13
  }
13
14
  export function warn(msg) {
14
- console.log(`${YELLOW}${msg}${RESET}`);
15
+ console.warn(`${YELLOW}${msg}${RESET}`);
15
16
  }
16
17
  export function error(msg) {
17
18
  console.error(`${RED}${msg}${RESET}`);
18
19
  }
20
+ export function log(msg) {
21
+ console.log(msg);
22
+ }
19
23
  export function bold(msg) {
20
24
  return `${BOLD}${msg}${RESET}`;
21
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joint/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "description": "Command-line tool for JointJS.",
6
6
  "license": "MIT",
@@ -12,10 +12,18 @@
12
12
  ],
13
13
  "scripts": {
14
14
  "build": "tsc",
15
+ "dist": "yarn build",
16
+ "test": "tsx --test src/__tests__/*.test.ts",
17
+ "lint": "eslint .",
18
+ "lint-fix": "eslint . --fix",
19
+ "prepack": "yarn build",
15
20
  "prepublishOnly": "echo \"Publishing via NPM is not allowed!\" && exit 1"
16
21
  },
17
22
  "devDependencies": {
23
+ "@joint/eslint-config": "4.2.3",
18
24
  "@types/node": "^22.0.0",
25
+ "eslint": "9.39.2",
26
+ "tsx": "^4.21.0",
19
27
  "typescript": "^5.8.0"
20
28
  },
21
29
  "publishConfig": {