@falldownthesystem/devtool 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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2025 FallDownTheSystem
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # dev
2
+
3
+ A terminal-first CLI for working with Bitbucket PRs, Jira tickets, and Confluence pages. Skip the browser, stay in your terminal.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @falldownthesystem/devtool
9
+ ```
10
+
11
+ This gives you the `dev` command globally. On first run, it'll walk you through connecting your Bitbucket, Jira, and Confluence accounts.
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ dev pr list # your open PRs
17
+ dev pr view 42 # PR details, reviewers, status
18
+ dev pr comments 42 # review comments
19
+ dev pr diff 42 # changed files
20
+ dev pr activity 42 # approvals, updates, comments
21
+
22
+ dev jira view PROJ-123 # ticket details
23
+ dev jira comments PROJ-123 # ticket comments
24
+ dev jira search "sprint = 5" # JQL search
25
+ dev jira mine # your open tickets
26
+
27
+ dev confluence spaces # list spaces
28
+ dev confluence view 12345 # read a page
29
+ dev confluence pages # pages in a space
30
+ dev confluence search "auth" # CQL search
31
+
32
+ dev config setup # re-run setup
33
+ dev config show # current config (tokens masked)
34
+ dev config set <key> <value> # set a single value
35
+ ```
36
+
37
+ Most commands have short aliases: `ls`, `read`, `find`, `my`.
38
+
39
+ ### Output modes
40
+
41
+ Every command supports three output formats:
42
+
43
+ ```bash
44
+ dev pr list # colored tables (default)
45
+ dev pr list --plain # no colors, no tables, pipe-friendly
46
+ dev pr list --json # curated JSON, not raw API dumps
47
+ ```
48
+
49
+ ## Config
50
+
51
+ Stored via the `conf` package in your OS config directory. You'll need:
52
+
53
+ - **Bitbucket**: workspace, default repo, email, app password
54
+ - **Jira**: base URL, API token, default project
55
+ - **Confluence**: uses the same Jira base URL and token
56
+
57
+ ## License
58
+
59
+ ISC
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@falldownthesystem/devtool",
3
+ "version": "0.1.0",
4
+ "description": "CLI for Bitbucket PRs, Jira tickets, and Confluence pages",
5
+ "type": "module",
6
+ "bin": {
7
+ "dev": "src/index.js"
8
+ },
9
+ "files": [
10
+ "src/"
11
+ ],
12
+ "scripts": {
13
+ "start": "node src/index.js"
14
+ },
15
+ "keywords": [
16
+ "cli",
17
+ "bitbucket",
18
+ "jira",
19
+ "confluence",
20
+ "pull-request",
21
+ "devtools"
22
+ ],
23
+ "author": "FallDownTheSystem",
24
+ "license": "ISC",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/FallDownTheSystem/devtool.git"
28
+ },
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "dependencies": {
33
+ "@clack/prompts": "^1.0.0",
34
+ "cli-table3": "^0.6.5",
35
+ "commander": "^14.0.3",
36
+ "conf": "^15.1.0",
37
+ "jira.js": "^5.3.0",
38
+ "picocolors": "^1.1.1"
39
+ }
40
+ }
@@ -0,0 +1,85 @@
1
+ import { get } from '../config/store.js';
2
+
3
+ const BASE_URL = 'https://api.bitbucket.org/2.0';
4
+
5
+ function authHeader() {
6
+ const email = get('bitbucket.email');
7
+ const token = get('bitbucket.token');
8
+ return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
9
+ }
10
+
11
+ function repoPath(repoOverride) {
12
+ const workspace = get('bitbucket.workspace');
13
+ const repo = repoOverride || get('bitbucket.defaultRepo');
14
+ return `/repositories/${workspace}/${repo}`;
15
+ }
16
+
17
+ async function request(path, params = {}) {
18
+ const url = new URL(path.startsWith('http') ? path : `${BASE_URL}${path}`);
19
+ for (const [k, v] of Object.entries(params)) {
20
+ if (v !== undefined && v !== null) url.searchParams.set(k, v);
21
+ }
22
+
23
+ const res = await fetch(url, {
24
+ headers: {
25
+ 'Authorization': authHeader(),
26
+ 'Accept': 'application/json',
27
+ },
28
+ });
29
+
30
+ if (!res.ok) {
31
+ const err = new Error(`Bitbucket API error: ${res.status} ${res.statusText}`);
32
+ err.status = res.status;
33
+ err.url = url.toString();
34
+ throw err;
35
+ }
36
+
37
+ return res.json();
38
+ }
39
+
40
+ async function paginate(path, params = {}) {
41
+ const results = [];
42
+ let data = await request(path, params);
43
+ results.push(...(data.values || []));
44
+
45
+ while (data.next) {
46
+ data = await request(data.next);
47
+ results.push(...(data.values || []));
48
+ }
49
+
50
+ return results;
51
+ }
52
+
53
+ export async function listPullRequests(options = {}) {
54
+ const { state = 'OPEN', author, repo } = options;
55
+ const path = `${repoPath(repo)}/pullrequests`;
56
+ const params = { state, pagelen: 50 };
57
+
58
+ const prs = await paginate(path, params);
59
+
60
+ if (author) {
61
+ const authorLower = author.toLowerCase();
62
+ return prs.filter((pr) =>
63
+ pr.author.display_name.toLowerCase().includes(authorLower)
64
+ || pr.author.nickname?.toLowerCase().includes(authorLower)
65
+ );
66
+ }
67
+
68
+ return prs;
69
+ }
70
+
71
+ export async function getPullRequest(id, repo) {
72
+ return request(`${repoPath(repo)}/pullrequests/${id}`);
73
+ }
74
+
75
+ export async function getPullRequestComments(id, repo) {
76
+ return paginate(`${repoPath(repo)}/pullrequests/${id}/comments`);
77
+ }
78
+
79
+ export async function getPullRequestDiffstat(id, repo) {
80
+ return paginate(`${repoPath(repo)}/pullrequests/${id}/diffstat`);
81
+ }
82
+
83
+ export async function getPullRequestActivity(id, repo) {
84
+ return paginate(`${repoPath(repo)}/pullrequests/${id}/activity`);
85
+ }
@@ -0,0 +1,63 @@
1
+ import { get } from '../config/store.js';
2
+
3
+ function baseUrl() {
4
+ return `${get('confluence.baseUrl')}/wiki`;
5
+ }
6
+
7
+ function authHeader() {
8
+ const email = get('confluence.email');
9
+ const token = get('confluence.token');
10
+ return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
11
+ }
12
+
13
+ async function request(path, params = {}) {
14
+ const url = new URL(path.startsWith('http') ? path : `${baseUrl()}${path}`);
15
+ for (const [k, v] of Object.entries(params)) {
16
+ if (v !== undefined && v !== null) url.searchParams.set(k, v);
17
+ }
18
+
19
+ const res = await fetch(url, {
20
+ headers: {
21
+ 'Authorization': authHeader(),
22
+ 'Accept': 'application/json',
23
+ },
24
+ });
25
+
26
+ if (!res.ok) {
27
+ const err = new Error(`Confluence API error: ${res.status} ${res.statusText}`);
28
+ err.status = res.status;
29
+ err.url = url.toString();
30
+ throw err;
31
+ }
32
+
33
+ return res.json();
34
+ }
35
+
36
+ async function paginateV2(path, params = {}) {
37
+ const results = [];
38
+ let data = await request(path, params);
39
+ results.push(...(data.results || []));
40
+
41
+ while (data._links?.next) {
42
+ data = await request(data._links.next);
43
+ results.push(...(data.results || []));
44
+ }
45
+
46
+ return results;
47
+ }
48
+
49
+ export async function listSpaces() {
50
+ return paginateV2('/api/v2/spaces');
51
+ }
52
+
53
+ export async function getPage(id) {
54
+ return request(`/api/v2/pages/${id}`, { 'body-format': 'storage' });
55
+ }
56
+
57
+ export async function listSpacePages(spaceId) {
58
+ return paginateV2(`/api/v2/spaces/${spaceId}/pages`);
59
+ }
60
+
61
+ export async function searchPages(cql) {
62
+ return request('/rest/api/search', { cql });
63
+ }
@@ -0,0 +1,61 @@
1
+ import { Version2Client } from 'jira.js';
2
+ import { get } from '../config/store.js';
3
+
4
+ let client = null;
5
+
6
+ function getClient() {
7
+ if (!client) {
8
+ const token = get('jira.token');
9
+ client = new Version2Client({
10
+ host: get('jira.baseUrl'),
11
+ authentication: {
12
+ basic: { email: '', apiToken: token },
13
+ },
14
+ });
15
+ client.instance.interceptors.request.use((config) => {
16
+ config.headers['Authorization'] = `Bearer ${token}`;
17
+ return config;
18
+ });
19
+ }
20
+ return client;
21
+ }
22
+
23
+ export async function getIssue(issueKey) {
24
+ return getClient().issues.getIssue({
25
+ issueIdOrKey: issueKey,
26
+ fields: ['*all'],
27
+ expand: 'renderedFields',
28
+ });
29
+ }
30
+
31
+ export async function getIssueComments(issueKey) {
32
+ const result = await getClient().issueComments.getComments({
33
+ issueIdOrKey: issueKey,
34
+ orderBy: 'created',
35
+ maxResults: 100,
36
+ });
37
+ return result.comments || [];
38
+ }
39
+
40
+ export async function searchIssues(jql, startAt = 0, maxResults = 50) {
41
+ const result = await getClient().issueSearch.searchForIssuesUsingJql({
42
+ jql,
43
+ startAt,
44
+ maxResults,
45
+ fields: ['key', 'summary', 'status', 'assignee', 'priority', 'updated'],
46
+ });
47
+ return {
48
+ issues: result.issues || [],
49
+ total: result.total || 0,
50
+ startAt: result.startAt || 0,
51
+ maxResults: result.maxResults || maxResults,
52
+ };
53
+ }
54
+
55
+ export async function getMyIssues() {
56
+ const project = get('jira.defaultProject');
57
+ const jql = project
58
+ ? `project = ${project} AND assignee = currentUser() AND status != Done ORDER BY updated DESC`
59
+ : `assignee = currentUser() AND status != Done ORDER BY updated DESC`;
60
+ return searchIssues(jql);
61
+ }
@@ -0,0 +1,68 @@
1
+ import { Command } from 'commander';
2
+ import { getAll, set, runSetup, configPath, isConfigured } from '../config/store.js';
3
+ import { heading, field, mask, success, error, divider } from '../utils/output.js';
4
+
5
+ const config = new Command('config')
6
+ .description('Manage configuration');
7
+
8
+ config
9
+ .command('setup')
10
+ .description('Run interactive setup')
11
+ .action(async () => {
12
+ await runSetup();
13
+ });
14
+
15
+ config
16
+ .command('show')
17
+ .alias('get')
18
+ .description('Show current configuration')
19
+ .action(() => {
20
+ if (!isConfigured()) {
21
+ error('Not configured yet. Run: dev config setup');
22
+ return;
23
+ }
24
+
25
+ const all = getAll();
26
+
27
+ heading('Bitbucket');
28
+ field('Workspace', all.bitbucket.workspace);
29
+ field('Default repo', all.bitbucket.defaultRepo);
30
+ field('Email', all.bitbucket.email);
31
+ field('Token', mask(all.bitbucket.token));
32
+
33
+ heading('Jira');
34
+ field('Base URL', all.jira.baseUrl);
35
+ field('Token', mask(all.jira.token));
36
+ field('Default project', all.jira.defaultProject);
37
+
38
+ heading('Confluence');
39
+ field('Base URL', all.confluence.baseUrl);
40
+ field('Email', all.confluence.email);
41
+ field('Token', mask(all.confluence.token));
42
+ field('Default space', all.confluence.defaultSpace);
43
+
44
+ console.log('');
45
+ divider();
46
+ field('Config file', configPath());
47
+ console.log('');
48
+ });
49
+
50
+ config
51
+ .command('set <key> <value>')
52
+ .description('Set a config value (e.g. bitbucket.workspace my-ws)')
53
+ .action((key, value) => {
54
+ const validKeys = [
55
+ 'bitbucket.workspace', 'bitbucket.defaultRepo', 'bitbucket.email', 'bitbucket.token',
56
+ 'jira.baseUrl', 'jira.token', 'jira.defaultProject',
57
+ 'confluence.baseUrl', 'confluence.email', 'confluence.token', 'confluence.defaultSpace',
58
+ ];
59
+ if (!validKeys.includes(key)) {
60
+ error(`Invalid key: ${key}`);
61
+ console.log(`Valid keys: ${validKeys.join(', ')}`);
62
+ return;
63
+ }
64
+ set(key, value);
65
+ success(`Set ${key}`);
66
+ });
67
+
68
+ export default config;
@@ -0,0 +1,113 @@
1
+ import { Command } from 'commander';
2
+ import {
3
+ listSpaces,
4
+ getPage,
5
+ listSpacePages,
6
+ searchPages,
7
+ } from '../clients/confluence.js';
8
+ import {
9
+ formatSpaces, formatPageView, formatPages, formatSearch,
10
+ plainSpaces, plainPageView, plainPages, plainSearch,
11
+ curateSpacesJson, curatePageJson, curatePagesJson, curateSearchJson,
12
+ } from '../formatters/confluence.js';
13
+ import { spinner } from '../utils/output.js';
14
+ import { handleError } from '../utils/errors.js';
15
+ import { getOutputMode, outputJson } from '../utils/mode.js';
16
+ import { get } from '../config/store.js';
17
+
18
+ const confluence = new Command('confluence')
19
+ .description('Confluence pages');
20
+
21
+ confluence.command('spaces')
22
+ .description('List all spaces')
23
+ .action(async function () {
24
+ const mode = getOutputMode(this);
25
+ const s = mode === 'rich' ? spinner('Fetching spaces...').start() : null;
26
+ try {
27
+ const spaces = await listSpaces();
28
+ if (s) s.success({ text: `${spaces.length} space(s) found` });
29
+ if (mode === 'json') outputJson(curateSpacesJson(spaces));
30
+ else if (mode === 'plain') plainSpaces(spaces);
31
+ else formatSpaces(spaces);
32
+ } catch (err) {
33
+ if (s) s.error({ text: 'Failed to fetch spaces' });
34
+ handleError(err, err.url || 'confluence');
35
+ }
36
+ });
37
+
38
+ confluence.command('view <id>')
39
+ .alias('read')
40
+ .description('View a page')
41
+ .action(async function (id) {
42
+ const mode = getOutputMode(this);
43
+ const s = mode === 'rich' ? spinner(`Fetching page ${id}...`).start() : null;
44
+ try {
45
+ const page = await getPage(id);
46
+ if (s) s.success({ text: page.title || `Page ${id}` });
47
+ if (mode === 'json') outputJson(curatePageJson(page));
48
+ else if (mode === 'plain') plainPageView(page);
49
+ else formatPageView(page);
50
+ } catch (err) {
51
+ if (s) s.error({ text: `Failed to fetch page ${id}` });
52
+ handleError(err, err.url || 'confluence');
53
+ }
54
+ });
55
+
56
+ confluence.command('pages')
57
+ .alias('ls')
58
+ .description('List pages in a space')
59
+ .option('-s, --space <key>', 'Space key (overrides default)')
60
+ .option('--space-id <id>', 'Space ID (skip key lookup)')
61
+ .action(async function (opts) {
62
+ const mode = getOutputMode(this);
63
+ const s = mode === 'rich' ? spinner('Fetching pages...').start() : null;
64
+ try {
65
+ let spaceId = opts.spaceId;
66
+ if (!spaceId) {
67
+ const spaceKey = opts.space || get('confluence.defaultSpace');
68
+ if (!spaceKey) {
69
+ if (s) s.error({ text: 'No space specified' });
70
+ console.error('Specify --space <key>, --space-id <id>, or set confluence.defaultSpace');
71
+ process.exitCode = 1;
72
+ return;
73
+ }
74
+ const spaces = await listSpaces();
75
+ const match = spaces.find((sp) => sp.key === spaceKey);
76
+ if (!match) {
77
+ if (s) s.error({ text: `Space "${spaceKey}" not found` });
78
+ process.exitCode = 1;
79
+ return;
80
+ }
81
+ spaceId = match.id;
82
+ }
83
+ const pages = await listSpacePages(spaceId);
84
+ if (s) s.success({ text: `${pages.length} page(s) found` });
85
+ if (mode === 'json') outputJson(curatePagesJson(pages));
86
+ else if (mode === 'plain') plainPages(pages);
87
+ else formatPages(pages);
88
+ } catch (err) {
89
+ if (s) s.error({ text: 'Failed to fetch pages' });
90
+ handleError(err, err.url || 'confluence');
91
+ }
92
+ });
93
+
94
+ confluence.command('search <cql>')
95
+ .alias('find')
96
+ .description('Search pages with CQL')
97
+ .action(async function (cql) {
98
+ const mode = getOutputMode(this);
99
+ const s = mode === 'rich' ? spinner('Searching...').start() : null;
100
+ try {
101
+ const result = await searchPages(cql);
102
+ const count = result.totalSize || (result.results || []).length;
103
+ if (s) s.success({ text: `${count} result(s)` });
104
+ if (mode === 'json') outputJson(curateSearchJson(result));
105
+ else if (mode === 'plain') plainSearch(result);
106
+ else formatSearch(result);
107
+ } catch (err) {
108
+ if (s) s.error({ text: 'Search failed' });
109
+ handleError(err, err.url || 'confluence');
110
+ }
111
+ });
112
+
113
+ export default confluence;
@@ -0,0 +1,95 @@
1
+ import { Command } from 'commander';
2
+ import {
3
+ getIssue,
4
+ getIssueComments,
5
+ searchIssues,
6
+ getMyIssues,
7
+ } from '../clients/jira.js';
8
+ import {
9
+ formatIssueView, formatIssueComments, formatIssueSearch,
10
+ plainIssueView, plainIssueComments, plainIssueSearch,
11
+ curateIssueJson, curateCommentsJson, curateSearchJson,
12
+ } from '../formatters/jira.js';
13
+ import { spinner } from '../utils/output.js';
14
+ import { handleError } from '../utils/errors.js';
15
+ import { getOutputMode, outputJson } from '../utils/mode.js';
16
+
17
+ const jira = new Command('jira')
18
+ .description('Jira tickets');
19
+
20
+ jira.command('view <key>')
21
+ .alias('read')
22
+ .description('View issue details')
23
+ .action(async function (key) {
24
+ const mode = getOutputMode(this);
25
+ const s = mode === 'rich' ? spinner(`Fetching ${key}...`).start() : null;
26
+ try {
27
+ const issue = await getIssue(key);
28
+ if (s) s.success({ text: key });
29
+ if (mode === 'json') outputJson(curateIssueJson(issue));
30
+ else if (mode === 'plain') plainIssueView(issue);
31
+ else formatIssueView(issue);
32
+ } catch (err) {
33
+ if (s) s.error({ text: `Failed to fetch ${key}` });
34
+ handleError(err, `jira issue ${key}`);
35
+ }
36
+ });
37
+
38
+ jira.command('comments <key>')
39
+ .description('List issue comments')
40
+ .action(async function (key) {
41
+ const mode = getOutputMode(this);
42
+ const s = mode === 'rich' ? spinner(`Fetching comments for ${key}...`).start() : null;
43
+ try {
44
+ const comments = await getIssueComments(key);
45
+ if (s) s.success({ text: `${comments.length} comment(s)` });
46
+ if (mode === 'json') outputJson(curateCommentsJson(comments));
47
+ else if (mode === 'plain') plainIssueComments(comments);
48
+ else formatIssueComments(comments);
49
+ } catch (err) {
50
+ if (s) s.error({ text: `Failed to fetch comments for ${key}` });
51
+ handleError(err, `jira comments ${key}`);
52
+ }
53
+ });
54
+
55
+ jira.command('search <jql>')
56
+ .alias('find')
57
+ .description('Search issues with JQL')
58
+ .option('-p, --page <number>', 'Page number', '1')
59
+ .option('-n, --per-page <number>', 'Results per page', '20')
60
+ .action(async function (jql, opts) {
61
+ const mode = getOutputMode(this);
62
+ const perPage = parseInt(opts.perPage, 10);
63
+ const startAt = (parseInt(opts.page, 10) - 1) * perPage;
64
+ const s = mode === 'rich' ? spinner('Searching...').start() : null;
65
+ try {
66
+ const result = await searchIssues(jql, startAt, perPage);
67
+ if (s) s.success({ text: `${result.total} issue(s) matched` });
68
+ if (mode === 'json') outputJson(curateSearchJson(result));
69
+ else if (mode === 'plain') plainIssueSearch(result);
70
+ else formatIssueSearch(result);
71
+ } catch (err) {
72
+ if (s) s.error({ text: 'Search failed' });
73
+ handleError(err, `jira search "${jql}"`);
74
+ }
75
+ });
76
+
77
+ jira.command('mine')
78
+ .alias('my')
79
+ .description('Show my open issues')
80
+ .action(async function () {
81
+ const mode = getOutputMode(this);
82
+ const s = mode === 'rich' ? spinner('Fetching my issues...').start() : null;
83
+ try {
84
+ const result = await getMyIssues();
85
+ if (s) s.success({ text: `${result.total} issue(s)` });
86
+ if (mode === 'json') outputJson(curateSearchJson(result));
87
+ else if (mode === 'plain') plainIssueSearch(result);
88
+ else formatIssueSearch(result);
89
+ } catch (err) {
90
+ if (s) s.error({ text: 'Failed to fetch issues' });
91
+ handleError(err, 'jira my issues');
92
+ }
93
+ });
94
+
95
+ export default jira;