@entrinsik/vite-plugin-informer 1.0.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/bin/deploy.js ADDED
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+
3
+ import dotenv from 'dotenv';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { resolve } from 'node:path';
6
+ import { deploy } from '../src/deploy.js';
7
+
8
+ dotenv.config();
9
+
10
+ const baseUrl = process.env.INFORMER_URL;
11
+ const apiKey = process.env.INFORMER_API_KEY;
12
+ const user = process.env.INFORMER_USER;
13
+ const pass = process.env.INFORMER_PASS;
14
+
15
+ if (!baseUrl || (!apiKey && (!user || !pass))) {
16
+ console.error('Missing required environment variables.');
17
+ console.error('Create a .env file with:');
18
+ console.error(' INFORMER_URL=http://localhost:3000');
19
+ console.error(' INFORMER_API_KEY=your-api-key');
20
+ console.error('Or use basic auth:');
21
+ console.error(' INFORMER_USER=admin');
22
+ console.error(' INFORMER_PASS=yourpassword');
23
+ process.exit(1);
24
+ }
25
+
26
+ // Read project metadata from package.json
27
+ let slug;
28
+ let displayName;
29
+ let description;
30
+ try {
31
+ const pkg = JSON.parse(await readFile(resolve('package.json'), 'utf8'));
32
+ slug = pkg.name;
33
+ // Strip scope prefix if present (e.g. @org/my-report -> my-report)
34
+ if (slug && slug.startsWith('@') && slug.includes('/')) {
35
+ slug = slug.split('/')[1];
36
+ }
37
+ // Read display name and description from informer config section
38
+ displayName = pkg.informer?.name;
39
+ description = pkg.informer?.description;
40
+ } catch {
41
+ console.error('Could not read package.json in current directory.');
42
+ process.exit(1);
43
+ }
44
+
45
+ if (!slug) {
46
+ console.error('package.json must have a "name" field.');
47
+ process.exit(1);
48
+ }
49
+
50
+ const distDir = resolve('dist');
51
+
52
+ try {
53
+ await deploy({ baseUrl, apiKey, user, pass, slug, distDir, name: displayName, description });
54
+ } catch (err) {
55
+ console.error('Deploy failed:', err.message);
56
+ process.exit(1);
57
+ }
package/bin/init.js ADDED
@@ -0,0 +1,434 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFile, writeFile, mkdir, cp, access, readdir } from 'node:fs/promises';
4
+ import { resolve, dirname, join, basename } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { createInterface } from 'node:readline';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const cwd = process.cwd();
10
+
11
+ /**
12
+ * Generate the default data-access.yaml content with helpful comments
13
+ */
14
+ function generateDataAccessYaml() {
15
+ return `# Data Access Configuration
16
+ # ========================
17
+ # This file controls which Informer APIs your report can access.
18
+ # Without this file, all API access is blocked (secure by default).
19
+ #
20
+ # Documentation: https://docs.entrinsik.com/informer/magic-reports/data-access
21
+
22
+ # ============================================================================
23
+ # DATASETS
24
+ # ============================================================================
25
+ # Grant access to dataset search and field metadata.
26
+ # Each entry generates:
27
+ # - POST /api/datasets/{id}/_search
28
+ # - GET /api/datasets/{id}/fields
29
+ #
30
+ # Simple access (full dataset):
31
+ # datasets:
32
+ # - admin:sales-data
33
+ # - admin:customers
34
+ #
35
+ # With row-level security (filter injected server-side):
36
+ # datasets:
37
+ # - id: admin:orders
38
+ # filter:
39
+ # region: $user.custom.region # User only sees their region
40
+ # sales_rep: $user.username # User only sees their own records
41
+ #
42
+ datasets: []
43
+
44
+ # ============================================================================
45
+ # QUERIES
46
+ # ============================================================================
47
+ # Grant access to execute saved queries.
48
+ # Each entry generates:
49
+ # - POST /api/queries/{id}/_execute
50
+ #
51
+ # Example:
52
+ # queries:
53
+ # - admin:daily-summary
54
+ # - admin:monthly-report
55
+ #
56
+ queries: []
57
+
58
+ # ============================================================================
59
+ # INTEGRATIONS
60
+ # ============================================================================
61
+ # Grant access to make requests through integrations (Salesforce, REST APIs, etc.)
62
+ # Each entry generates:
63
+ # - POST /api/integrations/{id}/request
64
+ #
65
+ # Simple access:
66
+ # integrations:
67
+ # - salesforce
68
+ # - quickbooks
69
+ #
70
+ # With credential injection (headers/params expanded server-side, never exposed to JS):
71
+ # integrations:
72
+ # - id: partner-api
73
+ # headers:
74
+ # Authorization: Bearer $user.custom.partnerToken
75
+ # X-Client-ID: $tenant.id
76
+ # params:
77
+ # user_id: $user.custom.externalId
78
+ #
79
+ # With path restrictions (only allow specific endpoints):
80
+ # integrations:
81
+ # - id: salesforce
82
+ # paths:
83
+ # - /data/*/query
84
+ # - /data/*/sobjects/*
85
+ #
86
+ integrations: []
87
+
88
+ # ============================================================================
89
+ # DATASOURCES
90
+ # ============================================================================
91
+ # Grant access to run SQL queries against datasources.
92
+ # Each entry generates:
93
+ # - POST /api/datasources/{id}/_query
94
+ #
95
+ # Example:
96
+ # datasources:
97
+ # - postgres-main
98
+ # - mysql-analytics
99
+ #
100
+ datasources: []
101
+
102
+ # ============================================================================
103
+ # LIBRARIES
104
+ # ============================================================================
105
+ # Grant access to read files from other libraries.
106
+ # Each entry generates:
107
+ # - GET /api/libraries/{id}/contents/*
108
+ #
109
+ # Example:
110
+ # libraries:
111
+ # - admin:shared-assets
112
+ # - admin:common-templates
113
+ #
114
+ libraries: []
115
+
116
+ # ============================================================================
117
+ # RAW API ACCESS (Advanced)
118
+ # ============================================================================
119
+ # For edge cases not covered by resource types above.
120
+ # Specify exact method and path.
121
+ #
122
+ # Example:
123
+ # apis:
124
+ # - POST /api/custom/endpoint
125
+ # - GET /api/special/resource
126
+ #
127
+ apis: []
128
+
129
+ # ============================================================================
130
+ # VARIABLE REFERENCE
131
+ # ============================================================================
132
+ # Variables are expanded server-side, keeping sensitive values secure.
133
+ #
134
+ # User variables:
135
+ # $user.username - Login name
136
+ # $user.email - Email address
137
+ # $user.displayName - Full name (e.g., "John Smith")
138
+ # $user.custom.xxx - Custom user field value
139
+ #
140
+ # Tenant variables:
141
+ # $tenant.id - Tenant identifier
142
+ #
143
+ # Report variables:
144
+ # $report.id - Report UUID
145
+ # $report.name - Report display name
146
+ #
147
+ `;
148
+ }
149
+
150
+ async function exists(path) {
151
+ try {
152
+ await access(path);
153
+ return true;
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Prompt user for input with a default value.
161
+ */
162
+ function prompt(question, defaultValue) {
163
+ const rl = createInterface({
164
+ input: process.stdin,
165
+ output: process.stdout
166
+ });
167
+
168
+ const displayDefault = defaultValue ? ` (${defaultValue})` : '';
169
+
170
+ return new Promise(resolve => {
171
+ rl.question(`${question}${displayDefault}: `, answer => {
172
+ rl.close();
173
+ resolve(answer.trim() || defaultValue);
174
+ });
175
+ });
176
+ }
177
+
178
+ /**
179
+ * Convert slug to friendly name.
180
+ * "magic-quickbooks-report" -> "Magic Quickbooks Report"
181
+ */
182
+ function slugToName(slug) {
183
+ return slug
184
+ .replace(/^@[^/]+\//, '') // Remove scope
185
+ .replace(/[-_]/g, ' ')
186
+ .replace(/\b\w/g, c => c.toUpperCase());
187
+ }
188
+
189
+ /**
190
+ * Convert name to slug.
191
+ * "Magic Quickbooks Report" -> "magic-quickbooks-report"
192
+ */
193
+ function nameToSlug(name) {
194
+ return name
195
+ .toLowerCase()
196
+ .replace(/[^a-z0-9]+/g, '-')
197
+ .replace(/^-|-$/g, '');
198
+ }
199
+
200
+ async function init() {
201
+ console.log('Initializing Informer Magic Report project...\n');
202
+
203
+ // 1. Check for package.json
204
+ const pkgPath = resolve(cwd, 'package.json');
205
+ if (!await exists(pkgPath)) {
206
+ console.error('No package.json found. Run this in a Vite project directory.');
207
+ console.error('Create one first with: npm create vite@latest');
208
+ process.exit(1);
209
+ }
210
+
211
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
212
+
213
+ // 2. Check for Vite
214
+ const hasVite = pkg.devDependencies?.vite || pkg.dependencies?.vite;
215
+ if (!hasVite) {
216
+ console.error('Vite not found in dependencies.');
217
+ console.error('Create a Vite project first: npm create vite@latest');
218
+ process.exit(1);
219
+ }
220
+
221
+ // 3. Get report info from user
222
+ const defaultSlug = (pkg.name || basename(cwd)).replace(/^@[^/]+\//, '');
223
+ const existingName = pkg.informer?.name;
224
+ const defaultName = existingName || slugToName(defaultSlug);
225
+
226
+ console.log('Configure your Magic Report:\n');
227
+
228
+ const reportName = await prompt('Report name', defaultName);
229
+ const reportSlug = await prompt('Report slug', nameToSlug(reportName) || defaultSlug);
230
+
231
+ console.log('');
232
+
233
+ // 4. Update package.json
234
+ if (!pkg.informer) pkg.informer = {};
235
+ pkg.informer.name = reportName;
236
+
237
+ // Update package name to match slug if it was the default
238
+ if (pkg.name === defaultSlug || !pkg.name) {
239
+ pkg.name = reportSlug;
240
+ }
241
+
242
+ // 5. Add plugin to dependencies if not present
243
+ if (!pkg.devDependencies) pkg.devDependencies = {};
244
+ if (!pkg.devDependencies['@entrinsik/vite-plugin-informer']) {
245
+ pkg.devDependencies['@entrinsik/vite-plugin-informer'] = '^1.0.0';
246
+ console.log('Added @entrinsik/vite-plugin-informer to devDependencies');
247
+ }
248
+
249
+ // 6. Add deploy script if not present
250
+ if (!pkg.scripts) pkg.scripts = {};
251
+ if (!pkg.scripts.deploy) {
252
+ pkg.scripts.deploy = 'npm run build && informer-deploy';
253
+ console.log('Added "deploy" script to package.json');
254
+ }
255
+
256
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
257
+ console.log(`Configured report: "${reportName}" (slug: ${reportSlug})`);
258
+
259
+ // 7. Update vite.config
260
+ await updateViteConfig();
261
+
262
+ // 8. Create .env.example
263
+ const envExample = `# Informer connection settings
264
+ INFORMER_URL=http://localhost:3000
265
+
266
+ # Option 1: API Key (recommended)
267
+ INFORMER_API_KEY=your-api-key
268
+
269
+ # Option 2: Basic auth
270
+ # INFORMER_USER=admin
271
+ # INFORMER_PASS=yourpassword
272
+
273
+ # Optional: defaults to current user
274
+ # INFORMER_OWNER=admin
275
+ `;
276
+
277
+ const envExamplePath = resolve(cwd, '.env.example');
278
+ if (!await exists(envExamplePath)) {
279
+ await writeFile(envExamplePath, envExample);
280
+ console.log('Created .env.example');
281
+ }
282
+
283
+ // Also create .env if it doesn't exist
284
+ const envPath = resolve(cwd, '.env');
285
+ if (!await exists(envPath)) {
286
+ await writeFile(envPath, envExample);
287
+ console.log('Created .env (update with your credentials)');
288
+ }
289
+
290
+ // 9. Create data-access.yaml if it doesn't exist
291
+ const dataAccessPath = resolve(cwd, 'data-access.yaml');
292
+ if (!await exists(dataAccessPath)) {
293
+ await writeFile(dataAccessPath, generateDataAccessYaml());
294
+ console.log('Created data-access.yaml (configure API access for your report)');
295
+ }
296
+
297
+ // 10. Copy Claude skill files
298
+ await copySkillFiles();
299
+
300
+ // 11. Add .env to .gitignore if not present
301
+ await updateGitignore();
302
+
303
+ console.log('\nSetup complete!\n');
304
+ console.log('Next steps:');
305
+ console.log(' 1. Update .env with your Informer credentials');
306
+ console.log(' 2. Update data-access.yaml with the datasets/APIs your report needs');
307
+ console.log(' 3. Run: npm install');
308
+ console.log(' 4. Run: npm run dev');
309
+ console.log(' 5. Open a terminal and run: claude');
310
+ console.log(' The informer skill is available in .claude/skills/');
311
+ console.log('');
312
+ }
313
+
314
+ async function updateViteConfig() {
315
+ // Find vite config file
316
+ const configFiles = ['vite.config.js', 'vite.config.ts', 'vite.config.mjs', 'vite.config.mts'];
317
+ let configPath = null;
318
+ let configContent = null;
319
+
320
+ for (const file of configFiles) {
321
+ const path = resolve(cwd, file);
322
+ if (await exists(path)) {
323
+ configPath = path;
324
+ configContent = await readFile(path, 'utf8');
325
+ break;
326
+ }
327
+ }
328
+
329
+ if (!configPath) {
330
+ // Create a basic vite.config.js
331
+ configPath = resolve(cwd, 'vite.config.js');
332
+ configContent = `import { defineConfig } from 'vite';
333
+ import informer from '@entrinsik/vite-plugin-informer';
334
+
335
+ export default defineConfig({
336
+ plugins: [informer()]
337
+ });
338
+ `;
339
+ await writeFile(configPath, configContent);
340
+ console.log('Created vite.config.js with informer plugin');
341
+ return;
342
+ }
343
+
344
+ // Check if plugin is already added
345
+ if (configContent.includes('vite-plugin-informer') || configContent.includes("informer()")) {
346
+ console.log('Informer plugin already in vite config');
347
+ return;
348
+ }
349
+
350
+ // Add import and plugin to existing config
351
+ const importLine = "import informer from '@entrinsik/vite-plugin-informer';\n";
352
+
353
+ // Add import after other imports
354
+ const lastImportMatch = configContent.match(/^import .+$/gm);
355
+ if (lastImportMatch) {
356
+ const lastImport = lastImportMatch[lastImportMatch.length - 1];
357
+ const lastImportIndex = configContent.lastIndexOf(lastImport) + lastImport.length;
358
+ configContent = configContent.slice(0, lastImportIndex) + '\n' + importLine + configContent.slice(lastImportIndex + 1);
359
+ } else {
360
+ configContent = importLine + configContent;
361
+ }
362
+
363
+ // Add to plugins array
364
+ const pluginsMatch = configContent.match(/plugins\s*:\s*\[/);
365
+ if (pluginsMatch) {
366
+ const insertIndex = pluginsMatch.index + pluginsMatch[0].length;
367
+ configContent = configContent.slice(0, insertIndex) + '\n informer(),' + configContent.slice(insertIndex);
368
+ } else {
369
+ // No plugins array, need to add one - this is trickier
370
+ // For now, warn the user
371
+ console.log('Could not automatically add plugin to vite config.');
372
+ console.log('Please add manually:');
373
+ console.log(" import informer from '@entrinsik/vite-plugin-informer';");
374
+ console.log(' // Then add informer() to plugins array');
375
+ return;
376
+ }
377
+
378
+ await writeFile(configPath, configContent);
379
+ console.log(`Updated ${configPath.split('/').pop()} with informer plugin`);
380
+ }
381
+
382
+ async function copySkillFiles() {
383
+ const skillSrc = resolve(__dirname, '..', 'templates', 'skill');
384
+ const skillDest = resolve(cwd, '.claude', 'skills', 'informer');
385
+
386
+ // Create destination directory
387
+ await mkdir(skillDest, { recursive: true });
388
+
389
+ // Copy files recursively
390
+ await copyDir(skillSrc, skillDest);
391
+ console.log('Copied Claude skill files to .claude/skills/informer/');
392
+ }
393
+
394
+ async function copyDir(src, dest) {
395
+ const entries = await readdir(src, { withFileTypes: true });
396
+
397
+ for (const entry of entries) {
398
+ const srcPath = join(src, entry.name);
399
+ const destPath = join(dest, entry.name);
400
+
401
+ if (entry.isDirectory()) {
402
+ await mkdir(destPath, { recursive: true });
403
+ await copyDir(srcPath, destPath);
404
+ } else {
405
+ await cp(srcPath, destPath);
406
+ }
407
+ }
408
+ }
409
+
410
+ async function updateGitignore() {
411
+ const gitignorePath = resolve(cwd, '.gitignore');
412
+ let content = '';
413
+
414
+ if (await exists(gitignorePath)) {
415
+ content = await readFile(gitignorePath, 'utf8');
416
+ }
417
+
418
+ const additions = [];
419
+
420
+ if (!content.includes('.env')) {
421
+ additions.push('.env');
422
+ }
423
+
424
+ if (additions.length > 0) {
425
+ const newContent = content.trimEnd() + '\n\n# Informer\n' + additions.join('\n') + '\n';
426
+ await writeFile(gitignorePath, newContent);
427
+ console.log('Updated .gitignore');
428
+ }
429
+ }
430
+
431
+ init().catch(err => {
432
+ console.error('Init failed:', err.message);
433
+ process.exit(1);
434
+ });
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@entrinsik/vite-plugin-informer",
3
+ "version": "1.0.0",
4
+ "description": "Vite plugin and publish tool for local Magic Report development",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./deploy": "./src/deploy.js"
10
+ },
11
+ "bin": {
12
+ "informer-deploy": "./bin/deploy.js",
13
+ "informer-init": "./bin/init.js"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "bin",
18
+ "templates"
19
+ ],
20
+ "peerDependencies": {
21
+ "vite": ">=5.0.0"
22
+ },
23
+ "dependencies": {
24
+ "dotenv": "^16.4.0"
25
+ },
26
+ "engines": {
27
+ "node": ">=18.0.0"
28
+ },
29
+ "license": "UNLICENSED"
30
+ }
package/src/client.js ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Informer API client using native fetch with Basic auth.
3
+ */
4
+
5
+ class InformerApiError extends Error {
6
+ constructor(status, statusText, body, url) {
7
+ super(`Informer API ${status} ${statusText}${url ? ` (${url})` : ''}`);
8
+ this.name = 'InformerApiError';
9
+ this.status = status;
10
+ this.statusText = statusText;
11
+ this.body = body;
12
+ this.url = url;
13
+ }
14
+ }
15
+
16
+ export { InformerApiError };
17
+
18
+ const CHUNK_SIZE = 1024 * 1024; // 1MB
19
+
20
+ /**
21
+ * Create an authenticated Informer API client.
22
+ * @param {{ baseUrl: string, apiKey?: string, user?: string, pass?: string }} options
23
+ * @returns {{ get, post, put, del, uploadChunked }}
24
+ */
25
+ export function createClient({ baseUrl, apiKey, user, pass }) {
26
+ const auth = apiKey
27
+ ? 'Bearer ' + apiKey
28
+ : 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
29
+ const origin = baseUrl.replace(/\/+$/, '');
30
+
31
+ async function request(method, path, body) {
32
+ const url = `${origin}/api/${path}`;
33
+ const headers = {
34
+ Authorization: auth,
35
+ Accept: 'application/json'
36
+ };
37
+
38
+ const opts = { method, headers };
39
+
40
+ if (body !== undefined) {
41
+ headers['Content-Type'] = 'application/json';
42
+ opts.body = JSON.stringify(body);
43
+ }
44
+
45
+ const res = await fetch(url, opts);
46
+
47
+ if (res.status === 404) return null;
48
+
49
+ if (!res.ok) {
50
+ let text;
51
+ try { text = await res.text(); } catch { text = ''; }
52
+ throw new InformerApiError(res.status, res.statusText, text, url);
53
+ }
54
+
55
+ const ct = res.headers.get('content-type') || '';
56
+ if (ct.includes('application/json') || ct.includes('hal+json')) {
57
+ return res.json();
58
+ }
59
+ return res.text();
60
+ }
61
+
62
+ /**
63
+ * Upload a file using chunked Flow.js protocol, then assemble into a report's library.
64
+ * @param {{ reportId: string, path: string, buffer: Buffer, filename: string }} opts
65
+ */
66
+ async function uploadChunked({ reportId, path, buffer, filename }) {
67
+ const totalChunks = Math.ceil(buffer.length / CHUNK_SIZE);
68
+ const uploadId = `publish-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
69
+
70
+ // Upload each chunk via Flow.js protocol
71
+ for (let i = 1; i <= totalChunks; i++) {
72
+ const start = (i - 1) * CHUNK_SIZE;
73
+ const end = Math.min(start + CHUNK_SIZE, buffer.length);
74
+ const chunk = buffer.slice(start, end);
75
+
76
+ const formData = new FormData();
77
+ formData.append('file', new Blob([chunk]), filename);
78
+ formData.append('flowChunkNumber', String(i));
79
+ formData.append('flowChunkSize', String(CHUNK_SIZE));
80
+ formData.append('flowCurrentChunkSize', String(chunk.length));
81
+ formData.append('flowFilename', filename);
82
+ formData.append('flowIdentifier', uploadId);
83
+ formData.append('flowRelativePath', filename);
84
+ formData.append('flowTotalChunks', String(totalChunks));
85
+ formData.append('flowTotalSize', String(buffer.length));
86
+
87
+ const res = await fetch(`${origin}/api/upload/flow`, {
88
+ method: 'POST',
89
+ headers: { Authorization: auth },
90
+ body: formData
91
+ });
92
+
93
+ if (!res.ok) {
94
+ let text;
95
+ try { text = await res.text(); } catch { text = ''; }
96
+ throw new InformerApiError(res.status, res.statusText, text, `${origin}/api/upload/flow`);
97
+ }
98
+ }
99
+
100
+ // Assemble chunks into a file in the report's library at the specified path
101
+ await request('POST', `reports/${reportId}/_upload`, {
102
+ uploadId,
103
+ path
104
+ });
105
+ }
106
+
107
+ return {
108
+ get: (path) => request('GET', path),
109
+ post: (path, body) => request('POST', path, body),
110
+ put: (path, body) => request('PUT', path, body),
111
+ del: (path) => request('DELETE', path),
112
+ uploadChunked
113
+ };
114
+ }