@fink-andreas/pi-linear-tools 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/src/logger.js ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Structured logging module
3
+ */
4
+
5
+ const LOG_LEVELS = ['debug', 'info', 'warn', 'error'];
6
+ let currentLevel = process.env.LOG_LEVEL || 'info';
7
+ let quietMode = false;
8
+
9
+ /**
10
+ * Enable quiet mode (suppress info/debug/warn, keep only errors)
11
+ */
12
+ export function setQuietMode(quiet) {
13
+ quietMode = quiet;
14
+ }
15
+
16
+ /**
17
+ * Check if a log level should be displayed
18
+ */
19
+ function shouldLog(level) {
20
+ if (quietMode && level !== 'error') return false;
21
+ const currentIndex = LOG_LEVELS.indexOf(currentLevel);
22
+ const levelIndex = LOG_LEVELS.indexOf(level);
23
+ return levelIndex >= currentIndex;
24
+ }
25
+
26
+ /**
27
+ * Format timestamp
28
+ */
29
+ function getTimestamp() {
30
+ return new Date().toISOString();
31
+ }
32
+
33
+ /**
34
+ * Mask sensitive values in logs
35
+ */
36
+ function maskValue(key, value) {
37
+ const sensitiveKeys = ['apiKey', 'token', 'password', 'secret', 'LINEAR_API_KEY'];
38
+ if (sensitiveKeys.some(sk => key.toLowerCase().includes(sk.toLowerCase()))) {
39
+ return '***masked***';
40
+ }
41
+ return value;
42
+ }
43
+
44
+ /**
45
+ * Format log entry
46
+ */
47
+ function formatLog(level, message, data = {}) {
48
+ const timestamp = getTimestamp();
49
+ const entry = {
50
+ timestamp,
51
+ level: level.toUpperCase(),
52
+ message,
53
+ ...data
54
+ };
55
+ return JSON.stringify(entry);
56
+ }
57
+
58
+ /**
59
+ * Log at debug level
60
+ */
61
+ export function debug(message, data = {}) {
62
+ if (shouldLog('debug')) {
63
+ console.log(formatLog('debug', message, data));
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Log at info level
69
+ */
70
+ export function info(message, data = {}) {
71
+ if (shouldLog('info')) {
72
+ console.log(formatLog('info', message, data));
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Log at warn level
78
+ */
79
+ export function warn(message, data = {}) {
80
+ if (shouldLog('warn')) {
81
+ console.log(formatLog('warn', message, data));
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Log at error level
87
+ */
88
+ export function error(message, data = {}) {
89
+ if (shouldLog('error')) {
90
+ console.error(formatLog('error', message, data));
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Print startup banner
96
+ */
97
+ export function printBanner() {
98
+ const banner = `
99
+ ╔════════════════════════════════════════════════════════════╗
100
+ ║ pi-linear-tools ║
101
+ ║ Pi extension tools for Linear SDK workflows ║
102
+ ╚════════════════════════════════════════════════════════════╝
103
+ `;
104
+ console.log(banner);
105
+ }
106
+
107
+ /**
108
+ * Log configuration summary (with secrets masked)
109
+ */
110
+ export function logConfig(config) {
111
+ info('Configuration loaded', {
112
+ ...Object.fromEntries(
113
+ Object.entries(config).map(([key, value]) => [key, maskValue(key, value)])
114
+ )
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Set log level
120
+ */
121
+ export function setLogLevel(level) {
122
+ if (LOG_LEVELS.includes(level)) {
123
+ currentLevel = level;
124
+ info(`Log level set to: ${level}`);
125
+ } else {
126
+ warn(`Invalid log level: ${level}. Using: ${currentLevel}`);
127
+ }
128
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Settings loader for pi-linear-tools
3
+ * Reads configuration from ~/.pi/agent/extensions/pi-linear-tools/settings.json
4
+ */
5
+
6
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
7
+ import { existsSync } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+ import { debug, warn, error as logError } from './logger.js';
10
+
11
+ export function getDefaultSettings() {
12
+ return {
13
+ schemaVersion: 1,
14
+ linearApiKey: null,
15
+ defaultTeam: null,
16
+ defaultWorkspace: null,
17
+ projects: {},
18
+ };
19
+ }
20
+
21
+ function migrateSettings(settings) {
22
+ const migrated = { ...(settings || {}) };
23
+
24
+ if (migrated.schemaVersion === undefined) {
25
+ migrated.schemaVersion = 1;
26
+ }
27
+
28
+ if (migrated.linearApiKey === undefined) {
29
+ migrated.linearApiKey = null;
30
+ }
31
+
32
+ if (migrated.defaultTeam === undefined) {
33
+ migrated.defaultTeam = null;
34
+ }
35
+
36
+ if (migrated.defaultWorkspace === undefined) {
37
+ migrated.defaultWorkspace = null;
38
+ }
39
+
40
+ if (!migrated.projects || typeof migrated.projects !== 'object' || Array.isArray(migrated.projects)) {
41
+ migrated.projects = {};
42
+ }
43
+
44
+ for (const [projectId, cfg] of Object.entries(migrated.projects)) {
45
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
46
+ migrated.projects[projectId] = { scope: { team: null } };
47
+ continue;
48
+ }
49
+
50
+ if (!cfg.scope || typeof cfg.scope !== 'object' || Array.isArray(cfg.scope)) {
51
+ cfg.scope = { team: null };
52
+ continue;
53
+ }
54
+
55
+ if (cfg.scope.team === undefined) {
56
+ cfg.scope.team = null;
57
+ }
58
+ }
59
+
60
+ return migrated;
61
+ }
62
+
63
+ export function validateSettings(settings) {
64
+ const errors = [];
65
+
66
+ if (!settings || typeof settings !== 'object') {
67
+ return { valid: false, errors: ['Settings must be an object'] };
68
+ }
69
+
70
+ if (typeof settings.schemaVersion !== 'number' || settings.schemaVersion < 1) {
71
+ errors.push('settings.schemaVersion must be a positive number');
72
+ }
73
+
74
+ if (settings.linearApiKey !== null && settings.linearApiKey !== undefined && typeof settings.linearApiKey !== 'string') {
75
+ errors.push('settings.linearApiKey must be a string or null');
76
+ }
77
+
78
+ if (settings.defaultTeam !== null && settings.defaultTeam !== undefined && typeof settings.defaultTeam !== 'string') {
79
+ errors.push('settings.defaultTeam must be a string or null');
80
+ }
81
+
82
+ if (settings.defaultWorkspace !== null && settings.defaultWorkspace !== undefined) {
83
+ if (typeof settings.defaultWorkspace !== 'object' || Array.isArray(settings.defaultWorkspace)) {
84
+ errors.push('settings.defaultWorkspace must be an object or null');
85
+ } else {
86
+ if (typeof settings.defaultWorkspace.id !== 'string' || !settings.defaultWorkspace.id.trim()) {
87
+ errors.push('settings.defaultWorkspace.id must be a non-empty string');
88
+ }
89
+ if (typeof settings.defaultWorkspace.name !== 'string' || !settings.defaultWorkspace.name.trim()) {
90
+ errors.push('settings.defaultWorkspace.name must be a non-empty string');
91
+ }
92
+ }
93
+ }
94
+
95
+ if (settings.projects !== undefined) {
96
+ if (typeof settings.projects !== 'object' || settings.projects === null || Array.isArray(settings.projects)) {
97
+ errors.push('settings.projects must be an object map keyed by Linear project id');
98
+ } else {
99
+ for (const [projectId, cfg] of Object.entries(settings.projects)) {
100
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
101
+ errors.push(`settings.projects.${projectId} must be an object`);
102
+ continue;
103
+ }
104
+
105
+ if (!cfg.scope || typeof cfg.scope !== 'object' || Array.isArray(cfg.scope)) {
106
+ errors.push(`settings.projects.${projectId}.scope must be an object`);
107
+ continue;
108
+ }
109
+
110
+ if (cfg.scope.team !== undefined && cfg.scope.team !== null && typeof cfg.scope.team !== 'string') {
111
+ errors.push(`settings.projects.${projectId}.scope.team must be a string or null`);
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ return { valid: errors.length === 0, errors };
118
+ }
119
+
120
+ export function getSettingsPath() {
121
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '.';
122
+ return join(homeDir, '.pi', 'agent', 'extensions', 'pi-linear-tools', 'settings.json');
123
+ }
124
+
125
+ export async function loadSettings() {
126
+ const settingsPath = getSettingsPath();
127
+ debug('Settings path', { path: settingsPath });
128
+
129
+ if (!existsSync(settingsPath)) {
130
+ debug('Settings file not found, using defaults', { path: settingsPath });
131
+ return getDefaultSettings();
132
+ }
133
+
134
+ try {
135
+ const content = await readFile(settingsPath, 'utf-8');
136
+ const parsed = JSON.parse(content);
137
+ const settings = migrateSettings(parsed);
138
+ const validation = validateSettings(settings);
139
+
140
+ if (!validation.valid) {
141
+ warn('Settings validation failed, using defaults', {
142
+ path: settingsPath,
143
+ errors: validation.errors,
144
+ });
145
+ return getDefaultSettings();
146
+ }
147
+
148
+ return settings;
149
+ } catch (err) {
150
+ if (err instanceof SyntaxError) {
151
+ logError('Settings file contains invalid JSON', { path: settingsPath, error: err.message });
152
+ } else {
153
+ logError('Failed to load settings file', { path: settingsPath, error: err.message });
154
+ }
155
+
156
+ return getDefaultSettings();
157
+ }
158
+ }
159
+
160
+ export async function saveSettings(settings) {
161
+ const settingsPath = getSettingsPath();
162
+ const parentDir = dirname(settingsPath);
163
+
164
+ const migrated = migrateSettings(settings);
165
+ const validation = validateSettings(migrated);
166
+ if (!validation.valid) {
167
+ throw new Error(`Cannot save invalid settings: ${validation.errors.join('; ')}`);
168
+ }
169
+
170
+ await mkdir(parentDir, { recursive: true });
171
+ await writeFile(settingsPath, `${JSON.stringify(migrated, null, 2)}\n`, 'utf-8');
172
+ return settingsPath;
173
+ }