@falldownthesystem/devtool 0.4.1 → 0.6.1

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.
@@ -0,0 +1,113 @@
1
+ import * as p from '@clack/prompts';
2
+ import pc from 'picocolors';
3
+ import {
4
+ types, label, listConnections, isDefault, saveConnection, validateName, resolveIndex, fieldProblem,
5
+ } from './store.js';
6
+
7
+ function answer(value) {
8
+ if (p.isCancel(value)) {
9
+ p.cancel('Setup cancelled.');
10
+ process.exit(0);
11
+ }
12
+ return (value ?? '').trim();
13
+ }
14
+
15
+ export function describe(connections, index) {
16
+ const connection = connections[index];
17
+ const { title } = types[connection.type];
18
+ const sameType = connections.filter((c) => c.type === connection.type).length;
19
+ const marker = sameType > 1 && isDefault(connections, index) ? ', default' : '';
20
+ return `${label(connection)} (${title}${marker})`;
21
+ }
22
+
23
+ async function chooseType() {
24
+ return answer(await p.select({
25
+ message: 'Connection type',
26
+ options: Object.entries(types).map(([value, { title }]) => ({ value, label: title })),
27
+ }));
28
+ }
29
+
30
+ // Service Desk Cloud often shares an Atlassian account with Confluence Cloud.
31
+ async function reusableCredentials(connections) {
32
+ const sources = connections.filter((c) => c.type === 'confluence' && c.email && c.token);
33
+ if (!sources.length) return null;
34
+ const choice = answer(await p.select({
35
+ message: 'Service Desk credentials',
36
+ options: [
37
+ ...sources.map((c) => ({ value: label(c), label: `Reuse email and token from Confluence connection "${label(c)}"` })),
38
+ { value: '', label: 'Enter new credentials' },
39
+ ],
40
+ }));
41
+ return sources.find((c) => label(c) === choice) ?? null;
42
+ }
43
+
44
+ async function promptConnection(type, existing = {}, index = -1) {
45
+ const connections = listConnections();
46
+ const connection = { type };
47
+ if (existing.default) connection.default = true;
48
+ if (types[type].note) p.note(types[type].note, types[type].title);
49
+
50
+ const unnamedTaken = connections.some((c, i) => i !== index && c.type === type && !c.name);
51
+ connection.name = answer(await p.text({
52
+ message: unnamedTaken ? 'Connection name' : 'Connection name (optional)',
53
+ placeholder: unnamedTaken ? 'work' : `leave empty to refer to it as "${type}"`,
54
+ initialValue: existing.name || '',
55
+ validate: (v) => validateName(connections, { type, name: (v ?? '').trim() }, index),
56
+ }));
57
+
58
+ const reused = type === 'serviceDesk' ? await reusableCredentials(connections) : null;
59
+
60
+ for (const [key, field] of Object.entries(types[type].fields)) {
61
+ if (reused && (key === 'email' || key === 'token')) {
62
+ connection[key] = reused[key];
63
+ continue;
64
+ }
65
+ const keep = field.secret && existing[key];
66
+ const message = keep ? `${field.message} (leave empty to keep the current one)` : field.message;
67
+ const validate = (v) => {
68
+ const value = (v ?? '').trim();
69
+ if (!value) return field.required && !keep ? 'Required' : undefined;
70
+ return fieldProblem(type, key, value);
71
+ };
72
+ const value = field.secret
73
+ ? answer(await p.password({ message, validate }))
74
+ : answer(await p.text({ message, placeholder: field.placeholder, initialValue: existing[key] || '', validate }));
75
+ connection[key] = value || (keep ? existing[key] : '');
76
+ }
77
+
78
+ saveConnection(connection, index);
79
+ return connection;
80
+ }
81
+
82
+ export async function addConnection(type, name) {
83
+ p.intro(pc.cyan('Add a connection'));
84
+ const connection = await promptConnection(type || await chooseType(), { name });
85
+ p.outro(pc.green(`Saved connection "${label(connection)}"`));
86
+ }
87
+
88
+ export async function editConnection(ref) {
89
+ const index = resolveIndex(ref);
90
+ const existing = listConnections()[index];
91
+ p.intro(pc.cyan(`Edit connection "${label(existing)}"`));
92
+ const connection = await promptConnection(existing.type, existing, index);
93
+ p.outro(pc.green(`Saved connection "${label(connection)}"`));
94
+ }
95
+
96
+ export async function runSetup() {
97
+ p.intro(pc.cyan('DevTool Setup'));
98
+ for (;;) {
99
+ const connections = listConnections();
100
+ const choice = answer(await p.select({
101
+ message: connections.length ? 'Edit a connection, or add a new one' : 'No connections configured yet',
102
+ options: [
103
+ ...connections.map((_, i) => ({ value: String(i), label: `Edit ${describe(connections, i)}` })),
104
+ { value: 'add', label: 'Add a connection' },
105
+ { value: 'done', label: 'Done' },
106
+ ],
107
+ }));
108
+ if (choice === 'done') break;
109
+ if (choice === 'add') await promptConnection(await chooseType());
110
+ else await promptConnection(connections[choice].type, connections[choice], Number(choice));
111
+ }
112
+ p.outro(pc.green('Configuration saved!'));
113
+ }
@@ -1,213 +1,249 @@
1
1
  import Conf from 'conf';
2
- import * as p from '@clack/prompts';
3
- import pc from 'picocolors';
4
2
 
5
- export const schema = {
3
+ export function validateGraphId(value) {
4
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value?.trim() || '')
5
+ ? undefined
6
+ : 'Enter the ID as a UUID from the Entra application Overview page.';
7
+ }
8
+
9
+ export function validateGraphSite(value) {
10
+ if (!value?.trim()) return undefined;
11
+ try {
12
+ const url = new URL(value.trim());
13
+ if (url.protocol === 'https:' && url.hostname.endsWith('.sharepoint.com') && !url.username && !url.password && !url.port && !url.search && !url.hash) {
14
+ decodeURIComponent(url.pathname);
15
+ return undefined;
16
+ }
17
+ } catch {}
18
+ return 'Enter a SharePoint Online site URL without a query or fragment.';
19
+ }
20
+
21
+ // A field's `validate` returns a problem message for a non-empty value; `normalize` runs on every save.
22
+ export const types = {
6
23
  bitbucket: {
7
- type: 'object',
8
24
  title: 'Bitbucket',
9
- properties: {
10
- workspace: { type: 'string', title: 'Workspace' },
11
- defaultRepo: { type: 'string', title: 'Default repo' },
12
- email: { type: 'string', title: 'Email' },
13
- token: { type: 'string', title: 'Token', writeOnly: true },
25
+ fields: {
26
+ workspace: { title: 'Workspace', message: 'Bitbucket workspace slug', placeholder: 'my-workspace', required: true },
27
+ defaultRepo: { title: 'Default repo', message: 'Bitbucket default repo slug', placeholder: 'my-repo' },
28
+ email: { title: 'Email', message: 'Bitbucket account email', placeholder: 'you@company.com', required: true },
29
+ token: { title: 'Token', message: 'Bitbucket API token', secret: true, required: true },
14
30
  },
15
31
  },
16
32
  jira: {
17
- type: 'object',
18
33
  title: 'Jira',
19
- properties: {
20
- baseUrl: { type: 'string', title: 'Base URL' },
21
- token: { type: 'string', title: 'Token', writeOnly: true },
22
- defaultProject: { type: 'string', title: 'Default project' },
34
+ fields: {
35
+ baseUrl: { title: 'Base URL', message: 'Jira Server base URL', placeholder: 'https://jira.company.com', required: true },
36
+ token: { title: 'Token', message: 'Jira Personal Access Token', secret: true, required: true },
37
+ defaultProject: { title: 'Default project', message: 'Jira default project key', placeholder: 'PROJ' },
23
38
  },
24
39
  },
25
40
  confluence: {
26
- type: 'object',
27
41
  title: 'Confluence',
28
- properties: {
29
- baseUrl: { type: 'string', title: 'Base URL' },
30
- email: { type: 'string', title: 'Email' },
31
- token: { type: 'string', title: 'Token', writeOnly: true },
32
- defaultSpace: { type: 'string', title: 'Default space' },
42
+ fields: {
43
+ baseUrl: { title: 'Base URL', message: 'Confluence base URL (Cloud or Server/Data Center)', placeholder: 'https://company.atlassian.net', required: true },
44
+ email: { title: 'Email', message: 'Confluence account email (Server/Data Center: username, or empty for a personal access token)', placeholder: 'you@company.com' },
45
+ token: { title: 'Token', message: 'Confluence API token (Server/Data Center: personal access token or password)', secret: true, required: true },
46
+ defaultSpace: { title: 'Default space', message: 'Confluence default space key', placeholder: 'DEV' },
33
47
  },
34
48
  },
35
49
  serviceDesk: {
36
- type: 'object',
37
50
  title: 'Service Desk',
38
- properties: {
39
- baseUrl: { type: 'string', title: 'Base URL' },
40
- email: { type: 'string', title: 'Email' },
41
- token: { type: 'string', title: 'Token', writeOnly: true },
42
- defaultProject: { type: 'string', title: 'Default project' },
51
+ fields: {
52
+ baseUrl: { title: 'Base URL', message: 'Jira Service Desk Cloud base URL (use canonical *.atlassian.net, not a vanity domain)', placeholder: 'https://company.atlassian.net', required: true },
53
+ email: { title: 'Email', message: 'Service Desk account email', placeholder: 'you@company.com', required: true },
54
+ token: { title: 'Token', message: 'Service Desk API token', secret: true, required: true },
55
+ defaultProject: { title: 'Default project', message: 'Service Desk default project key', placeholder: 'TUKI' },
56
+ },
57
+ },
58
+ graph: {
59
+ title: 'Microsoft Graph',
60
+ note: 'Teams, email, calendars, and SharePoint share this application. No client secret is needed.\nRegister http://localhost under Mobile and desktop applications.\nDelegated permissions: User.Read, Chat.Read, Mail.Read, Sites.Read.All.\nOptional: Calendars.Read.Shared and Calendars.ReadBasic for calendars/free-busy; Mail.Read.Shared for shared email.\nAfter saving, run dev graph login (--all for one sign-in with every supported permission).',
61
+ fields: {
62
+ tenantId: { title: 'Tenant ID', message: 'Directory (tenant) ID', required: true, validate: validateGraphId, normalize: (v) => v.trim().toLowerCase() },
63
+ clientId: { title: 'Client ID', message: 'Application (client) ID', required: true, validate: validateGraphId, normalize: (v) => v.trim().toLowerCase() },
64
+ defaultSite: { title: 'Default site', message: 'Default SharePoint site URL (optional)', placeholder: 'https://company.sharepoint.com/sites/MySite', validate: validateGraphSite, normalize: (v) => v.trim().replace(/\/+$/, '') },
43
65
  },
44
66
  },
45
67
  };
46
68
 
69
+ export function fieldProblem(type, key, value) {
70
+ const field = types[type].fields[key];
71
+ return value && field.validate ? field.validate(value) : undefined;
72
+ }
73
+
47
74
  const config = new Conf({
48
75
  projectName: 'devtool',
49
- schema,
50
- defaults: {
51
- bitbucket: { workspace: '', defaultRepo: '', email: '', token: '' },
52
- jira: { baseUrl: '', token: '', defaultProject: '' },
53
- confluence: { baseUrl: '', email: '', token: '', defaultSpace: '' },
54
- serviceDesk: { baseUrl: '', email: '', token: '', defaultProject: '' },
76
+ schema: {
77
+ connections: {
78
+ type: 'array',
79
+ items: {
80
+ type: 'object',
81
+ properties: {
82
+ type: { type: 'string', enum: Object.keys(types) },
83
+ name: { type: 'string' },
84
+ default: { type: 'boolean' },
85
+ },
86
+ required: ['type'],
87
+ additionalProperties: { type: 'string' },
88
+ },
89
+ },
55
90
  },
91
+ defaults: { connections: [] },
56
92
  });
57
93
 
58
- export function get(key) {
59
- return config.get(key);
94
+ migrateLegacySections();
95
+
96
+ // Older versions stored exactly one connection per type as a top-level section.
97
+ function migrateLegacySections() {
98
+ const { connections = [], ...rest } = config.store;
99
+ const legacy = Object.keys(rest).filter((key) => types[key]);
100
+ if (!legacy.length) return;
101
+ for (const type of legacy) {
102
+ const connection = clean({ type, ...rest[type] });
103
+ const hasValues = Object.keys(connection).length > 1;
104
+ if (hasValues && !connections.some((c) => c.type === type && !c.name)) connections.push(connection);
105
+ delete rest[type];
106
+ }
107
+ config.store = { ...rest, connections };
108
+ }
109
+
110
+ function clean(connection) {
111
+ const result = { type: connection.type };
112
+ if (connection.name) result.name = connection.name;
113
+ if (connection.default) result.default = true;
114
+ for (const [key, field] of Object.entries(types[connection.type].fields)) {
115
+ if (typeof connection[key] !== 'string') continue;
116
+ const value = field.normalize ? field.normalize(connection[key]) : connection[key];
117
+ if (value !== '') result[key] = value;
118
+ }
119
+ return result;
60
120
  }
61
121
 
62
- export function set(key, value) {
63
- config.set(key, value);
122
+ function write(connections) {
123
+ config.set('connections', connections.map(clean));
64
124
  }
65
125
 
66
- export function getAll() {
67
- return config.store;
126
+ // An unnamed connection is addressed by its type, so labels stay unique across all connections.
127
+ export function label(connection) {
128
+ return connection.name || connection.type;
68
129
  }
69
130
 
70
- export function isConfigured() {
71
- const bb = config.get('bitbucket');
72
- const jira = config.get('jira');
73
- return !!(bb.workspace && bb.token && jira.baseUrl && jira.token);
131
+ export function listConnections() {
132
+ return config.get('connections');
74
133
  }
75
134
 
76
- export function configPath() {
77
- return config.path;
135
+ function defaultIndex(connections, type) {
136
+ const flagged = connections.findIndex((c) => c.type === type && c.default);
137
+ return flagged !== -1 ? flagged : connections.findIndex((c) => c.type === type);
78
138
  }
79
139
 
80
- export async function runSetup() {
81
- p.intro(pc.cyan('DevTool Setup'));
82
-
83
- const bb = await p.group({
84
- workspace: () => p.text({
85
- message: 'Bitbucket workspace slug',
86
- placeholder: 'my-workspace',
87
- initialValue: config.get('bitbucket.workspace') || '',
88
- validate: (v) => !v ? 'Required' : undefined,
89
- }),
90
- defaultRepo: () => p.text({
91
- message: 'Bitbucket default repo slug',
92
- placeholder: 'my-repo',
93
- initialValue: config.get('bitbucket.defaultRepo') || '',
94
- validate: (v) => !v ? 'Required' : undefined,
95
- }),
96
- email: () => p.text({
97
- message: 'Bitbucket account email',
98
- placeholder: 'you@company.com',
99
- initialValue: config.get('bitbucket.email') || '',
100
- validate: (v) => !v ? 'Required' : undefined,
101
- }),
102
- token: () => p.password({
103
- message: 'Bitbucket API token',
104
- validate: (v) => !v ? 'Required' : undefined,
105
- }),
106
- }, {
107
- onCancel: () => {
108
- p.cancel('Setup cancelled.');
109
- process.exit(0);
110
- },
111
- });
112
-
113
- const jira = await p.group({
114
- baseUrl: () => p.text({
115
- message: 'Jira Server base URL',
116
- placeholder: 'https://jira.company.com',
117
- initialValue: config.get('jira.baseUrl') || '',
118
- validate: (v) => !v ? 'Required' : undefined,
119
- }),
120
- token: () => p.password({
121
- message: 'Jira Personal Access Token',
122
- validate: (v) => !v ? 'Required' : undefined,
123
- }),
124
- defaultProject: () => p.text({
125
- message: 'Jira default project key',
126
- placeholder: 'PROJ',
127
- initialValue: config.get('jira.defaultProject') || '',
128
- validate: (v) => !v ? 'Required' : undefined,
129
- }),
130
- }, {
131
- onCancel: () => {
132
- p.cancel('Setup cancelled.');
133
- process.exit(0);
134
- },
135
- });
136
-
137
- const confluence = await p.group({
138
- baseUrl: () => p.text({
139
- message: 'Confluence Cloud base URL',
140
- placeholder: 'https://company.atlassian.net',
141
- initialValue: config.get('confluence.baseUrl') || '',
142
- }),
143
- email: () => p.text({
144
- message: 'Confluence account email',
145
- placeholder: 'you@company.com',
146
- initialValue: config.get('confluence.email') || '',
147
- }),
148
- token: () => p.password({
149
- message: 'Confluence API token',
150
- }),
151
- defaultSpace: () => p.text({
152
- message: 'Confluence default space key',
153
- placeholder: 'DEV',
154
- initialValue: config.get('confluence.defaultSpace') || '',
155
- }),
156
- }, {
157
- onCancel: () => {
158
- p.cancel('Setup cancelled.');
159
- process.exit(0);
160
- },
161
- });
162
-
163
- const reuseConfluenceCreds = confluence.email && confluence.token
164
- ? await p.confirm({
165
- message: 'Reuse Confluence email/token for Jira Service Desk Cloud?',
166
- initialValue: true,
167
- })
168
- : false;
169
-
170
- if (p.isCancel(reuseConfluenceCreds)) {
171
- p.cancel('Setup cancelled.');
172
- process.exit(0);
140
+ export function isDefault(connections, index) {
141
+ return defaultIndex(connections, connections[index].type) === index;
142
+ }
143
+
144
+ // A reference is a connection label; a bare type falls back to that type's default connection.
145
+ function findIndex(connections, ref) {
146
+ const byLabel = connections.findIndex((c) => label(c) === ref);
147
+ if (byLabel !== -1 || !types[ref]) return byLabel;
148
+ return defaultIndex(connections, ref);
149
+ }
150
+
151
+ function requireIndex(connections, ref) {
152
+ const index = findIndex(connections, ref);
153
+ if (index === -1) throw new Error(`No connection named "${ref}". Run \`dev config show\` to list connections.`);
154
+ return index;
155
+ }
156
+
157
+ export function getConnection(type, ref) {
158
+ const connections = listConnections();
159
+ const { title } = types[type];
160
+ if (ref) {
161
+ const connection = connections[requireIndex(connections, ref)];
162
+ if (connection.type !== type) {
163
+ throw new Error(`Connection "${ref}" is a ${types[connection.type].title} connection, not ${title}.`);
164
+ }
165
+ return connection;
173
166
  }
167
+ const index = defaultIndex(connections, type);
168
+ if (index === -1) throw new Error(`No ${title} connection configured. Run \`dev config add ${type}\` to add one.`);
169
+ return connections[index];
170
+ }
174
171
 
175
- const serviceDesk = await p.group({
176
- baseUrl: () => p.text({
177
- message: 'Jira Service Desk Cloud base URL (use canonical *.atlassian.net, not a vanity domain)',
178
- placeholder: 'https://company.atlassian.net',
179
- initialValue: config.get('serviceDesk.baseUrl') || '',
180
- }),
181
- email: () => reuseConfluenceCreds
182
- ? Promise.resolve(confluence.email)
183
- : p.text({
184
- message: 'Service Desk account email',
185
- placeholder: 'you@company.com',
186
- initialValue: config.get('serviceDesk.email') || '',
187
- }),
188
- token: () => reuseConfluenceCreds
189
- ? Promise.resolve(confluence.token)
190
- : p.password({
191
- message: 'Service Desk API token',
192
- }),
193
- defaultProject: () => p.text({
194
- message: 'Service Desk default project key',
195
- placeholder: 'TUKI',
196
- initialValue: config.get('serviceDesk.defaultProject') || '',
197
- }),
198
- }, {
199
- onCancel: () => {
200
- p.cancel('Setup cancelled.');
201
- process.exit(0);
202
- },
203
- });
172
+ export function resolveIndex(ref) {
173
+ return requireIndex(listConnections(), ref);
174
+ }
204
175
 
205
- config.set('bitbucket', bb);
206
- config.set('jira', jira);
207
- config.set('confluence', confluence);
208
- config.set('serviceDesk', serviceDesk);
176
+ export function validateName(connections, connection, index = -1) {
177
+ const { name, type } = connection;
178
+ if (name) {
179
+ if (!/^[A-Za-z0-9][\w-]*$/.test(name)) {
180
+ return 'Use only letters, digits, "-" and "_", starting with a letter or digit';
181
+ }
182
+ if (Object.keys(types).some((t) => t.toLowerCase() === name.toLowerCase())) {
183
+ return `"${name}" is a connection type and cannot be a name`;
184
+ }
185
+ }
186
+ const clash = connections.some((c, i) => i !== index && label(c) === label(connection));
187
+ if (clash) {
188
+ return name
189
+ ? `A connection named "${name}" already exists`
190
+ : `A ${types[type].title} connection without a name already exists; give this one a name`;
191
+ }
192
+ return undefined;
193
+ }
209
194
 
210
- p.outro(pc.green('Configuration saved!'));
195
+ // Without an index the connection is appended.
196
+ export function saveConnection(connection, index = -1) {
197
+ const connections = listConnections();
198
+ const problem = validateName(connections, connection, index);
199
+ if (problem) throw new Error(problem);
200
+ if (index === -1) connections.push(connection);
201
+ else connections[index] = connection;
202
+ write(connections);
203
+ }
204
+
205
+ export function removeConnection(ref) {
206
+ const connections = listConnections();
207
+ const [removed] = connections.splice(requireIndex(connections, ref), 1);
208
+ write(connections);
209
+ return removed;
210
+ }
211
+
212
+ export function setDefault(ref) {
213
+ const connections = listConnections();
214
+ const target = connections[requireIndex(connections, ref)];
215
+ for (const c of connections) {
216
+ if (c.type === target.type) c.default = c === target;
217
+ }
218
+ write(connections);
219
+ return target;
220
+ }
221
+
222
+ // A bare type with no connection yet creates one, so `config set jira.baseUrl ...` works on a fresh install.
223
+ export function setField(ref, field, value) {
224
+ const connections = listConnections();
225
+ let index = findIndex(connections, ref);
226
+ if (index === -1) {
227
+ if (!types[ref]) throw new Error(`No connection named "${ref}". Run \`dev config show\` to list connections.`);
228
+ connections.push({ type: ref });
229
+ index = connections.length - 1;
230
+ }
231
+ const connection = connections[index];
232
+ const fields = Object.keys(types[connection.type].fields);
233
+ if (field !== 'name' && !fields.includes(field)) {
234
+ throw new Error(`Invalid field "${field}" for a ${types[connection.type].title} connection. Valid fields: name, ${fields.join(', ')}`);
235
+ }
236
+ connection[field] = value;
237
+ const problem = field === 'name'
238
+ ? validateName(connections, connection, index)
239
+ : fieldProblem(connection.type, field, value);
240
+ if (problem) throw new Error(problem);
241
+ write(connections);
242
+ return connection;
243
+ }
244
+
245
+ export function configPath() {
246
+ return config.path;
211
247
  }
212
248
 
213
249
  export default config;