viepilot 2.48.0 → 2.50.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.
- package/CHANGELOG.md +52 -0
- package/lib/intake/adapters/excel-m365.cjs +209 -6
- package/lib/intake/channels.cjs +44 -3
- package/lib/intake/classifier.cjs +2 -0
- package/lib/intake/setup-wizard.cjs +215 -0
- package/package.json +3 -2
- package/skills/vp-intake/SKILL.md +43 -7
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.50.1] - 2026-05-17
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **BUG** SharePoint sharing link download: forward FedAuth session cookies from WOPI page
|
|
14
|
+
redirect chain to the `download.aspx` download request — without these cookies the download
|
|
15
|
+
returned HTTP 401 or an HTML error page even though incognito browser worked fine.
|
|
16
|
+
`httpsGetRaw` now accumulates `Set-Cookie` headers across all hops; `downloadXlsx` retries
|
|
17
|
+
with cookies when the initial response is not a valid xlsx binary.
|
|
18
|
+
|
|
19
|
+
## [2.50.0] - 2026-05-17
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- **ENH-084** `vp-intake` Setup Wizard — AUQ-driven channel configuration (Phase 125):
|
|
23
|
+
- **Auto-trigger**: wizard runs automatically when `channels.json` has no real channels
|
|
24
|
+
(only scaffold stubs with `-example` IDs or `REPLACE_WITH_*` values)
|
|
25
|
+
- **`--setup` / `--config` flags**: force wizard even when channels already exist
|
|
26
|
+
- **4-type wizard** via `lib/intake/setup-wizard.cjs`: CSV/TSV, Google Sheets, Excel M365
|
|
27
|
+
(Graph API), SharePoint Sharing Link — collects all required fields via AUQ + free-text
|
|
28
|
+
- **Credentials guide**: for Google Sheets and M365, wizard prints step-by-step setup
|
|
29
|
+
instructions before asking for config values (no live validation)
|
|
30
|
+
- **Preview + confirm**: shows JSON preview of channel config before writing
|
|
31
|
+
- **`appendChannel()`**: strips scaffold stubs, preserves non-stub channels, appends new
|
|
32
|
+
- **`isStubChannel()` / `hasRealChannels()`**: stub detection helpers in `channels.cjs`
|
|
33
|
+
- **Auto-select after wizard**: if exactly 1 channel created → skips channel selection AUQ
|
|
34
|
+
- **`validateChannel` fix**: accepts `sharing_url` as valid alternative to `workbook_id`
|
|
35
|
+
for `excel_m365` channels (ENH-083 compatibility)
|
|
36
|
+
- **18 new tests** (1915 total): stub detection (5), hasRealChannels (5), appendChannel (2),
|
|
37
|
+
slugify (3), extractSpreadsheetId (3)
|
|
38
|
+
- `vp-intake` skill version bumped to **v1.1.0**
|
|
39
|
+
|
|
40
|
+
## [2.49.0] - 2026-05-17
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
- **ENH-083** `vp-intake` — SharePoint sharing link + xlsx direct download support (Phase 124):
|
|
44
|
+
- **SharePoint anonymous sharing link** adapter: URLs matching `/:x:/g/` or `/:x:/r/` are
|
|
45
|
+
resolved via the SharePoint WOPI viewer page — `FileGetUrl` (temp-auth token) is scraped
|
|
46
|
+
from the HTML and used to download the xlsx file without OAuth credentials
|
|
47
|
+
- **xlsx parser** using SheetJS (`xlsx` npm package): downloaded xlsx Buffer is parsed to
|
|
48
|
+
`rows[][]` matching the Graph API response format; falls back to first sheet when the
|
|
49
|
+
configured sheet name is not found
|
|
50
|
+
- **`sharing_url` field** in `channels.json` (`excel_m365` type): optional alternative to
|
|
51
|
+
`workbook_id` for SharePoint "Anyone with the link" files; no credentials required
|
|
52
|
+
- **Redirect-following HTTP client** (`httpsGetRaw`): browser User-Agent, follows 301/302/307
|
|
53
|
+
chains needed for SharePoint sharing link flow
|
|
54
|
+
- **Routing logic in `readExcelM365`**: `sharing_url` present → anonymous WOPI flow;
|
|
55
|
+
`workbook_id` present → Graph API (OAuth); neither present → `AuthRequiredError` with guide
|
|
56
|
+
- **BUG_KEYWORDS extended**: `performance`, `slow`, `timeout`, `hang`, `freeze`,
|
|
57
|
+
`unresponsive`, `chậm`, `treo`, `tắc`, `không phản hồi` — "performance issue" now correctly
|
|
58
|
+
classifies as BUG instead of UNCLEAR
|
|
59
|
+
- **19 new tests** (1897 total): keyword expansion (8 tests), `isSharingLink` detection (5
|
|
60
|
+
tests), routing logic (3 tests), `parseXlsxBuffer` (3 tests)
|
|
61
|
+
|
|
10
62
|
## [2.48.0] - 2026-05-17
|
|
11
63
|
|
|
12
64
|
### Added
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
4
5
|
const path = require('path');
|
|
5
6
|
const https = require('https');
|
|
6
7
|
|
|
@@ -16,6 +17,9 @@ Microsoft 365 / Excel setup:
|
|
|
16
17
|
5. In channels.json, set "workbook_id" to the file's drive item ID
|
|
17
18
|
(visible in the file's URL on SharePoint/OneDrive)
|
|
18
19
|
6. Re-run vp-intake
|
|
20
|
+
|
|
21
|
+
For SharePoint sharing links ("Anyone with the link"), use "sharing_url" in channels.json
|
|
22
|
+
instead of "workbook_id" — no credentials required.
|
|
19
23
|
`;
|
|
20
24
|
|
|
21
25
|
class AuthRequiredError extends Error {
|
|
@@ -35,6 +39,8 @@ function colLetterToIndex(letter) {
|
|
|
35
39
|
return index - 1;
|
|
36
40
|
}
|
|
37
41
|
|
|
42
|
+
// ─── HTTP helpers ─────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
38
44
|
function httpsPost(url, body, headers) {
|
|
39
45
|
return new Promise((resolve, reject) => {
|
|
40
46
|
const payload = typeof body === 'string' ? body : JSON.stringify(body);
|
|
@@ -73,12 +79,66 @@ function httpsGet(url, headers) {
|
|
|
73
79
|
});
|
|
74
80
|
}
|
|
75
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Follow redirects and return {status, headers, body, cookies}.
|
|
84
|
+
* Cookies (Set-Cookie) are accumulated across all redirects and forwarded
|
|
85
|
+
* on each hop — required for SharePoint FedAuth session cookies.
|
|
86
|
+
*/
|
|
87
|
+
function httpsGetRaw(url, headers = {}, maxRedirects = 8) {
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
function doGet(currentUrl, remaining, accCookies) {
|
|
90
|
+
const urlObj = new URL(currentUrl);
|
|
91
|
+
const reqHeaders = {
|
|
92
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
93
|
+
...headers,
|
|
94
|
+
};
|
|
95
|
+
if (accCookies) reqHeaders['Cookie'] = accCookies;
|
|
96
|
+
|
|
97
|
+
const req = https.request({
|
|
98
|
+
hostname: urlObj.hostname,
|
|
99
|
+
path: urlObj.pathname + urlObj.search,
|
|
100
|
+
method: 'GET',
|
|
101
|
+
headers: reqHeaders,
|
|
102
|
+
}, (res) => {
|
|
103
|
+
// Accumulate Set-Cookie headers across redirects
|
|
104
|
+
const setCookies = (res.headers['set-cookie'] || []).map((c) => c.split(';')[0]);
|
|
105
|
+
const newCookies = setCookies.length
|
|
106
|
+
? [accCookies, ...setCookies].filter(Boolean).join('; ')
|
|
107
|
+
: accCookies;
|
|
108
|
+
|
|
109
|
+
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && remaining > 0) {
|
|
110
|
+
let nextUrl = res.headers.location;
|
|
111
|
+
if (!nextUrl.startsWith('http')) {
|
|
112
|
+
nextUrl = `${urlObj.protocol}//${urlObj.host}${nextUrl}`;
|
|
113
|
+
}
|
|
114
|
+
// Drain response body before following redirect
|
|
115
|
+
res.resume();
|
|
116
|
+
return doGet(nextUrl, remaining - 1, newCookies);
|
|
117
|
+
}
|
|
118
|
+
const chunks = [];
|
|
119
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
120
|
+
res.on('end', () => resolve({
|
|
121
|
+
status: res.statusCode,
|
|
122
|
+
headers: res.headers,
|
|
123
|
+
body: Buffer.concat(chunks),
|
|
124
|
+
cookies: newCookies,
|
|
125
|
+
}));
|
|
126
|
+
});
|
|
127
|
+
req.on('error', reject);
|
|
128
|
+
req.end();
|
|
129
|
+
}
|
|
130
|
+
doGet(url, maxRedirects, '');
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
76
134
|
function encodeFormData(obj) {
|
|
77
135
|
return Object.entries(obj)
|
|
78
136
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
79
137
|
.join('&');
|
|
80
138
|
}
|
|
81
139
|
|
|
140
|
+
// ─── Graph API (client credentials) ──────────────────────────────────────────
|
|
141
|
+
|
|
82
142
|
async function getAccessToken(creds) {
|
|
83
143
|
const now = Math.floor(Date.now() / 1000);
|
|
84
144
|
if (_tokenCache && _tokenCache.expires_at > now + 60) {
|
|
@@ -101,7 +161,7 @@ async function getAccessToken(creds) {
|
|
|
101
161
|
return res.access_token;
|
|
102
162
|
}
|
|
103
163
|
|
|
104
|
-
async function
|
|
164
|
+
async function readViaGraphApi(channel, projectRoot) {
|
|
105
165
|
const root = projectRoot || process.cwd();
|
|
106
166
|
const credPath = path.join(root, CREDENTIALS_REL);
|
|
107
167
|
|
|
@@ -126,12 +186,121 @@ async function readExcelM365(channel, projectRoot) {
|
|
|
126
186
|
const response = await httpsGet(url, { Authorization: `Bearer ${token}` });
|
|
127
187
|
|
|
128
188
|
if (!response.values || response.values.length === 0) return [];
|
|
189
|
+
return parseRowsWithColumnMap(response.values, channel.column_map);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── SharePoint sharing link (anonymous WOPI) ─────────────────────────────────
|
|
129
193
|
|
|
130
|
-
|
|
194
|
+
/** Detect sharing link type: /:x:/g/ or /:x:/r/ paths */
|
|
195
|
+
function isSharingLink(url) {
|
|
196
|
+
return /\/:x:\/[gr]\//.test(url);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Resolve a SharePoint anonymous sharing link to {downloadUrl, cookies}.
|
|
201
|
+
* Technique: load WOPI viewer page → scrape FileGetUrl (temp auth token embedded in HTML).
|
|
202
|
+
* Returns the cookies collected during the redirect chain — they must be forwarded
|
|
203
|
+
* when downloading (SharePoint FedAuth cookie is required for download.aspx).
|
|
204
|
+
*/
|
|
205
|
+
async function resolveSharePointDownloadUrl(sharingUrl) {
|
|
206
|
+
const { status, body, cookies } = await httpsGetRaw(sharingUrl);
|
|
207
|
+
if (status !== 200) {
|
|
208
|
+
throw new Error(`SharePoint sharing link returned HTTP ${status}. The link may be expired or restricted.`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const html = body.toString('utf8');
|
|
212
|
+
|
|
213
|
+
// Scrape FileGetUrl — SharePoint embeds a temp-auth download URL in the viewer HTML
|
|
214
|
+
const match = html.match(/"FileGetUrl"\s*:\s*"([^"]+)"/);
|
|
215
|
+
if (!match) {
|
|
216
|
+
throw new Error('Could not extract download URL from SharePoint viewer page. The file may require sign-in.');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Unescape JSON unicode escapes (& → &)
|
|
220
|
+
const downloadUrl = match[1].replace(/\\u([\da-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
221
|
+
return { downloadUrl, cookies };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Download xlsx bytes.
|
|
226
|
+
* cookies: FedAuth cookies from WOPI page — required for download.aspx redirect chain.
|
|
227
|
+
*/
|
|
228
|
+
async function downloadXlsx(downloadUrl, cookies) {
|
|
229
|
+
const extraHeaders = { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,*/*' };
|
|
230
|
+
const { status, body } = await httpsGetRaw(downloadUrl, extraHeaders, 8);
|
|
231
|
+
if (status !== 200) {
|
|
232
|
+
// Retry with cookies if initial attempt fails (some SharePoint tenants need FedAuth)
|
|
233
|
+
if (cookies) {
|
|
234
|
+
const retry = await httpsGetRaw(downloadUrl, { ...extraHeaders, Cookie: cookies }, 8);
|
|
235
|
+
if (retry.status === 200 && retry.body.length > 100) return retry.body;
|
|
236
|
+
}
|
|
237
|
+
throw new Error(`Failed to download xlsx: HTTP ${status}`);
|
|
238
|
+
}
|
|
239
|
+
// If we got HTML instead of binary (redirect page), retry with cookies
|
|
240
|
+
const magic = body.slice(0, 4).toString('hex');
|
|
241
|
+
if (magic !== '504b0304' && cookies) {
|
|
242
|
+
const retry = await httpsGetRaw(downloadUrl, { ...extraHeaders, Cookie: cookies }, 8);
|
|
243
|
+
if (retry.status === 200 && retry.body.slice(0, 4).toString('hex') === '504b0304') {
|
|
244
|
+
return retry.body;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return body;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ─── xlsx parser (SheetJS) ────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Parse xlsx Buffer → array of row arrays (like response.values from Graph API).
|
|
254
|
+
* Uses the `xlsx` npm package (SheetJS).
|
|
255
|
+
*/
|
|
256
|
+
function parseXlsxBuffer(buffer, sheetName) {
|
|
257
|
+
let XLSX;
|
|
258
|
+
try {
|
|
259
|
+
XLSX = require('xlsx');
|
|
260
|
+
} catch {
|
|
261
|
+
throw new Error(
|
|
262
|
+
'The "xlsx" package is required to parse xlsx files from sharing links.\n' +
|
|
263
|
+
'Install it: npm install xlsx'
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const workbook = XLSX.read(buffer, { type: 'buffer' });
|
|
268
|
+
|
|
269
|
+
let sheet;
|
|
270
|
+
if (sheetName && workbook.SheetNames.includes(sheetName)) {
|
|
271
|
+
sheet = workbook.Sheets[sheetName];
|
|
272
|
+
} else {
|
|
273
|
+
// Default to first sheet; warn if requested sheet not found
|
|
274
|
+
if (sheetName) {
|
|
275
|
+
process.stderr.write(`[vp-intake] Sheet "${sheetName}" not found. Using "${workbook.SheetNames[0]}" instead.\n`);
|
|
276
|
+
}
|
|
277
|
+
sheet = workbook.Sheets[workbook.SheetNames[0]];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (!sheet) return [];
|
|
281
|
+
|
|
282
|
+
// Convert to array of arrays (raw values)
|
|
283
|
+
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' });
|
|
284
|
+
return rows;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function readViaSharingLink(channel) {
|
|
288
|
+
const sharingUrl = channel.sharing_url;
|
|
289
|
+
const { downloadUrl, cookies } = await resolveSharePointDownloadUrl(sharingUrl);
|
|
290
|
+
const xlsxBuffer = await downloadXlsx(downloadUrl, cookies);
|
|
291
|
+
const rows = parseXlsxBuffer(xlsxBuffer, channel.sheet_name || null);
|
|
292
|
+
|
|
293
|
+
if (!rows || rows.length === 0) return [];
|
|
294
|
+
return parseRowsWithColumnMap(rows, channel.column_map);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ─── Common row → ticket mapper ───────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
function parseRowsWithColumnMap(values, colMap) {
|
|
131
300
|
const tickets = [];
|
|
132
301
|
|
|
133
|
-
for (let i = 1; i <
|
|
134
|
-
const row =
|
|
302
|
+
for (let i = 1; i < values.length; i++) {
|
|
303
|
+
const row = values[i];
|
|
135
304
|
const get = (field) => {
|
|
136
305
|
const col = colMap[field];
|
|
137
306
|
if (!col) return '';
|
|
@@ -147,7 +316,7 @@ async function readExcelM365(channel, projectRoot) {
|
|
|
147
316
|
date: get('date'),
|
|
148
317
|
status: get('status'),
|
|
149
318
|
_source_row: i,
|
|
150
|
-
_channel_id: channel.id,
|
|
319
|
+
_channel_id: channel ? channel.id : undefined,
|
|
151
320
|
};
|
|
152
321
|
|
|
153
322
|
if (!ticket.title && !ticket.description) continue;
|
|
@@ -157,6 +326,40 @@ async function readExcelM365(channel, projectRoot) {
|
|
|
157
326
|
return tickets;
|
|
158
327
|
}
|
|
159
328
|
|
|
329
|
+
// ─── Public entry point ───────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
async function readExcelM365(channel, projectRoot) {
|
|
332
|
+
// Sharing link mode: no credentials required
|
|
333
|
+
if (channel.sharing_url) {
|
|
334
|
+
if (isSharingLink(channel.sharing_url)) {
|
|
335
|
+
return readViaSharingLink(channel);
|
|
336
|
+
}
|
|
337
|
+
throw new Error(
|
|
338
|
+
`sharing_url "${channel.sharing_url}" is not a SharePoint sharing link.\n` +
|
|
339
|
+
'Sharing links look like: https://xxx.sharepoint.com/:x:/g/personal/xxx/EdXXX...\n' +
|
|
340
|
+
'For direct document links (Doc.aspx?sourcedoc=...), use workbook_id + credentials instead.'
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Graph API mode: requires workbook_id + credentials
|
|
345
|
+
if (!channel.workbook_id) {
|
|
346
|
+
throw new AuthRequiredError(
|
|
347
|
+
'excel_m365 channel requires either "sharing_url" (for SharePoint sharing links) or ' +
|
|
348
|
+
'"workbook_id" (for Graph API).' + SETUP_GUIDE
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return readViaGraphApi(channel, projectRoot);
|
|
353
|
+
}
|
|
354
|
+
|
|
160
355
|
function clearTokenCache() { _tokenCache = null; }
|
|
161
356
|
|
|
162
|
-
module.exports = {
|
|
357
|
+
module.exports = {
|
|
358
|
+
readExcelM365,
|
|
359
|
+
AuthRequiredError,
|
|
360
|
+
clearTokenCache,
|
|
361
|
+
// exported for testing
|
|
362
|
+
isSharingLink,
|
|
363
|
+
resolveSharePointDownloadUrl,
|
|
364
|
+
parseXlsxBuffer,
|
|
365
|
+
};
|
package/lib/intake/channels.cjs
CHANGED
|
@@ -93,8 +93,8 @@ function validateChannel(channel) {
|
|
|
93
93
|
if (channel.type === 'google_sheets' && !channel.spreadsheet_id) {
|
|
94
94
|
throw new Error(`Google Sheets channel "${channel.id}" must have a "spreadsheet_id" field`);
|
|
95
95
|
}
|
|
96
|
-
if (channel.type === 'excel_m365' && !channel.workbook_id) {
|
|
97
|
-
throw new Error(`Excel/M365 channel "${channel.id}" must have
|
|
96
|
+
if (channel.type === 'excel_m365' && !channel.workbook_id && !channel.sharing_url) {
|
|
97
|
+
throw new Error(`Excel/M365 channel "${channel.id}" must have "workbook_id" or "sharing_url"`);
|
|
98
98
|
}
|
|
99
99
|
return true;
|
|
100
100
|
}
|
|
@@ -136,4 +136,45 @@ function initIntakeDir(projectRoot) {
|
|
|
136
136
|
return { intakeDir, channelsPath, credentialsDir };
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
|
|
139
|
+
/** Returns true if the channel is an unfilled scaffold stub. */
|
|
140
|
+
function isStubChannel(ch) {
|
|
141
|
+
if (!ch || !ch.id) return true;
|
|
142
|
+
if (ch.id.endsWith('-example')) return true;
|
|
143
|
+
return JSON.stringify(ch).includes('REPLACE_WITH_');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Returns true if channels array has at least one non-stub channel. */
|
|
147
|
+
function hasRealChannels(channels) {
|
|
148
|
+
return Array.isArray(channels) && channels.some((ch) => !isStubChannel(ch));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Append a new channel to channels.json.
|
|
153
|
+
* Strips stub channels; preserves existing non-stub channels.
|
|
154
|
+
*/
|
|
155
|
+
function appendChannel(projectRoot, channelObj) {
|
|
156
|
+
initIntakeDir(projectRoot);
|
|
157
|
+
const channelsPath = path.join(projectRoot, CHANNELS_REL);
|
|
158
|
+
let existing = [];
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(fs.readFileSync(channelsPath, 'utf8'));
|
|
161
|
+
existing = Array.isArray(parsed.channels) ? parsed.channels : [];
|
|
162
|
+
} catch {
|
|
163
|
+
existing = [];
|
|
164
|
+
}
|
|
165
|
+
const nonStubs = existing.filter((ch) => !isStubChannel(ch));
|
|
166
|
+
const updated = [...nonStubs, channelObj];
|
|
167
|
+
fs.writeFileSync(channelsPath, JSON.stringify({ channels: updated }, null, 2) + '\n', 'utf8');
|
|
168
|
+
return channelObj;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = {
|
|
172
|
+
loadChannels,
|
|
173
|
+
validateChannel,
|
|
174
|
+
getChannelById,
|
|
175
|
+
initIntakeDir,
|
|
176
|
+
CHANNELS_SCAFFOLD,
|
|
177
|
+
isStubChannel,
|
|
178
|
+
hasRealChannels,
|
|
179
|
+
appendChannel,
|
|
180
|
+
};
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
const BUG_KEYWORDS = [
|
|
4
4
|
'bug', 'error', 'fail', 'crash', 'broken', 'fix', 'exception', 'regression',
|
|
5
5
|
'wrong', 'incorrect', 'unexpected', 'null', 'undefined', '500', '404',
|
|
6
|
+
'performance', 'slow', 'timeout', 'hang', 'freeze', 'unresponsive',
|
|
6
7
|
// Vietnamese
|
|
7
8
|
'lỗi', 'sự cố', 'không hoạt động', 'hỏng', 'sai', 'vá lỗi', 'bị lỗi',
|
|
9
|
+
'chậm', 'treo', 'tắc', 'không phản hồi',
|
|
8
10
|
];
|
|
9
11
|
|
|
10
12
|
const ENH_KEYWORDS = [
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { appendChannel, initIntakeDir } = require('./channels.cjs');
|
|
5
|
+
const { isSharingLink } = require('./adapters/excel-m365.cjs');
|
|
6
|
+
|
|
7
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
function slugify(name) {
|
|
10
|
+
return name
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
13
|
+
.replace(/^-+|-+$/g, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function extractSpreadsheetId(urlOrId) {
|
|
17
|
+
const match = urlOrId.match(/\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/);
|
|
18
|
+
return match ? match[1] : urlOrId.trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function printCredentialsGuide(type) {
|
|
22
|
+
if (type === 'google_sheets') {
|
|
23
|
+
return [
|
|
24
|
+
'',
|
|
25
|
+
' Google Sheets — Service Account setup:',
|
|
26
|
+
' 1. Go to console.cloud.google.com → IAM & Admin → Service Accounts → Create',
|
|
27
|
+
' 2. Create a key (JSON) → download it',
|
|
28
|
+
' 3. Share your Google Sheet with the service account email',
|
|
29
|
+
' 4. Save the JSON key to: .viepilot/.credentials/google-service-account.json',
|
|
30
|
+
'',
|
|
31
|
+
].join('\n');
|
|
32
|
+
}
|
|
33
|
+
if (type === 'excel_m365') {
|
|
34
|
+
return [
|
|
35
|
+
'',
|
|
36
|
+
' Excel M365 — Azure App Registration:',
|
|
37
|
+
' 1. Go to portal.azure.com → Azure Active Directory → App Registrations → New',
|
|
38
|
+
' 2. Add API permission: Microsoft Graph → Files.Read.All (Application)',
|
|
39
|
+
' 3. Grant admin consent → Certificates & Secrets → New client secret',
|
|
40
|
+
' 4. Save to: .viepilot/.credentials/m365-credentials.json',
|
|
41
|
+
' { "tenant_id": "...", "client_id": "...", "client_secret": "..." }',
|
|
42
|
+
'',
|
|
43
|
+
].join('\n');
|
|
44
|
+
}
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Per-type config collectors ───────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
async function collectCsvConfig(name, id, askFn) {
|
|
51
|
+
const filePath = await askFn('Đường dẫn file CSV/TSV? (relative to project root, vd: ./reports/tickets.csv)', null, false);
|
|
52
|
+
const titleCol = await askFn('Header tên cột TITLE là gì? (vd: summary)', null, false);
|
|
53
|
+
const descCol = await askFn('Header tên cột DESCRIPTION là gì? (vd: details)', null, false);
|
|
54
|
+
const repCol = await askFn('Header tên cột REPORTER? (để trống nếu không có)', null, false);
|
|
55
|
+
const dateCol = await askFn('Header tên cột DATE? (để trống nếu không có)', null, false);
|
|
56
|
+
|
|
57
|
+
const column_map = { title: titleCol, description: descCol };
|
|
58
|
+
if (repCol) column_map.reporter = repCol;
|
|
59
|
+
if (dateCol) column_map.date = dateCol;
|
|
60
|
+
|
|
61
|
+
return { id, type: 'csv', name, path: filePath, column_map };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function collectGSheetsConfig(name, id, askFn) {
|
|
65
|
+
const rawId = await askFn('Spreadsheet ID hoặc URL Google Sheets?', null, false);
|
|
66
|
+
const spreadId = extractSpreadsheetId(rawId);
|
|
67
|
+
const sheetName = await askFn('Sheet name? (mặc định: Sheet1 — để trống dùng Sheet1)', null, false) || 'Sheet1';
|
|
68
|
+
const titleCol = await askFn('Cột TITLE ở cột letter nào? (vd: B)', null, false);
|
|
69
|
+
const descCol = await askFn('Cột DESCRIPTION? (vd: C)', null, false);
|
|
70
|
+
const repCol = await askFn('Cột REPORTER? (optional, để trống nếu không có)', null, false);
|
|
71
|
+
const dateCol = await askFn('Cột DATE? (optional)', null, false);
|
|
72
|
+
|
|
73
|
+
const column_map = { title: titleCol.toUpperCase(), description: descCol.toUpperCase() };
|
|
74
|
+
if (repCol) column_map.reporter = repCol.toUpperCase();
|
|
75
|
+
if (dateCol) column_map.date = dateCol.toUpperCase();
|
|
76
|
+
|
|
77
|
+
return { id, type: 'google_sheets', name, spreadsheet_id: spreadId, sheet_name: sheetName, column_map };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function collectM365Config(name, id, askFn) {
|
|
81
|
+
const workbookId = await askFn('Workbook ID? (từ SharePoint URL, dạng item ID)', null, false);
|
|
82
|
+
const sheetName = await askFn('Sheet name? (mặc định: Sheet1)', null, false) || 'Sheet1';
|
|
83
|
+
const titleCol = await askFn('Cột TITLE? (letter, vd: B)', null, false);
|
|
84
|
+
const descCol = await askFn('Cột DESCRIPTION? (vd: C)', null, false);
|
|
85
|
+
const repCol = await askFn('Cột REPORTER? (optional)', null, false);
|
|
86
|
+
const dateCol = await askFn('Cột DATE? (optional)', null, false);
|
|
87
|
+
|
|
88
|
+
const column_map = { title: titleCol.toUpperCase(), description: descCol.toUpperCase() };
|
|
89
|
+
if (repCol) column_map.reporter = repCol.toUpperCase();
|
|
90
|
+
if (dateCol) column_map.date = dateCol.toUpperCase();
|
|
91
|
+
|
|
92
|
+
return { id, type: 'excel_m365', name, workbook_id: workbookId, sheet_name: sheetName, column_map };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function collectSharePointConfig(name, id, askFn) {
|
|
96
|
+
let sharingUrl = '';
|
|
97
|
+
while (!isSharingLink(sharingUrl)) {
|
|
98
|
+
sharingUrl = await askFn(
|
|
99
|
+
'SharePoint sharing URL? (dạng https://xxx.sharepoint.com/:x:/g/... hoặc /:x:/r/...)',
|
|
100
|
+
null,
|
|
101
|
+
false
|
|
102
|
+
);
|
|
103
|
+
if (!isSharingLink(sharingUrl)) {
|
|
104
|
+
process.stdout.write(' ⚠ URL không đúng định dạng sharing link. Vui lòng dán đúng URL chia sẻ.\n');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const sheetName = await askFn('Sheet name? (để trống = dùng sheet đầu tiên)', null, false) || '';
|
|
109
|
+
const titleCol = await askFn('Cột TITLE? (letter, vd: B)', null, false);
|
|
110
|
+
const descCol = await askFn('Cột DESCRIPTION? (vd: C)', null, false);
|
|
111
|
+
const repCol = await askFn('Cột REPORTER? (optional)', null, false);
|
|
112
|
+
const dateCol = await askFn('Cột DATE? (optional)', null, false);
|
|
113
|
+
|
|
114
|
+
const column_map = { title: titleCol.toUpperCase(), description: descCol.toUpperCase() };
|
|
115
|
+
if (repCol) column_map.reporter = repCol.toUpperCase();
|
|
116
|
+
if (dateCol) column_map.date = dateCol.toUpperCase();
|
|
117
|
+
|
|
118
|
+
const ch = { id, type: 'excel_m365', name, sharing_url: sharingUrl, column_map };
|
|
119
|
+
if (sheetName) ch.sheet_name = sheetName;
|
|
120
|
+
return ch;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── Single channel wizard ────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
const CHANNEL_TYPE_OPTIONS = [
|
|
126
|
+
{ label: 'CSV / TSV file (local)', description: 'Đọc từ file .csv hoặc .tsv trên máy' },
|
|
127
|
+
{ label: 'Google Sheets', description: 'Đọc từ Google Sheets qua Service Account' },
|
|
128
|
+
{ label: 'Excel M365 — Graph API (Azure App)', description: 'Đọc từ OneDrive/SharePoint qua Microsoft Graph API' },
|
|
129
|
+
{ label: 'SharePoint Sharing Link (no credentials)', description: 'Dán link chia sẻ SharePoint — không cần cấu hình OAuth' },
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
async function wizardOneChannel(projectRoot, askFn) {
|
|
133
|
+
// Step 1 — loại channel
|
|
134
|
+
const typeChoice = await askFn(
|
|
135
|
+
'Bạn muốn cấu hình loại nguồn ticket nào?',
|
|
136
|
+
CHANNEL_TYPE_OPTIONS,
|
|
137
|
+
false
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// Step 2 — display name → id
|
|
141
|
+
const name = await askFn('Tên hiển thị cho channel này? (vd: UAT Tickets Q2)', null, false);
|
|
142
|
+
const id = slugify(name) || `channel-${Date.now()}`;
|
|
143
|
+
|
|
144
|
+
// Step 3 — collect per-type fields
|
|
145
|
+
let channelObj;
|
|
146
|
+
if (typeChoice === 'CSV / TSV file (local)') {
|
|
147
|
+
channelObj = await collectCsvConfig(name, id, askFn);
|
|
148
|
+
} else if (typeChoice === 'Google Sheets') {
|
|
149
|
+
process.stdout.write(printCredentialsGuide('google_sheets'));
|
|
150
|
+
channelObj = await collectGSheetsConfig(name, id, askFn);
|
|
151
|
+
} else if (typeChoice === 'Excel M365 — Graph API (Azure App)') {
|
|
152
|
+
process.stdout.write(printCredentialsGuide('excel_m365'));
|
|
153
|
+
channelObj = await collectM365Config(name, id, askFn);
|
|
154
|
+
} else {
|
|
155
|
+
// SharePoint Sharing Link
|
|
156
|
+
channelObj = await collectSharePointConfig(name, id, askFn);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Step 4 — preview + confirm
|
|
160
|
+
process.stdout.write('\n Preview cấu hình:\n');
|
|
161
|
+
process.stdout.write(JSON.stringify(channelObj, null, 2).split('\n').map(l => ' ' + l).join('\n'));
|
|
162
|
+
process.stdout.write('\n');
|
|
163
|
+
|
|
164
|
+
const confirm = await askFn('Lưu cấu hình này?', [
|
|
165
|
+
{ label: 'Lưu', description: 'Ghi channel vào channels.json' },
|
|
166
|
+
{ label: 'Cấu hình lại', description: 'Nhập lại thông tin channel này' },
|
|
167
|
+
{ label: 'Huỷ', description: 'Bỏ qua channel này' },
|
|
168
|
+
], false);
|
|
169
|
+
|
|
170
|
+
if (confirm === 'Cấu hình lại') {
|
|
171
|
+
return wizardOneChannel(projectRoot, askFn);
|
|
172
|
+
}
|
|
173
|
+
if (confirm === 'Huỷ') {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Step 5 — write
|
|
178
|
+
appendChannel(projectRoot, channelObj);
|
|
179
|
+
process.stdout.write(`\n ✓ Channel "${name}" đã được lưu vào .viepilot/intake/channels.json\n\n`);
|
|
180
|
+
return channelObj;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── Public entry point ───────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Run the interactive setup wizard.
|
|
187
|
+
* @param {string} projectRoot
|
|
188
|
+
* @param {Function} askFn async (question, options, multiSelect) → string
|
|
189
|
+
* @returns {object|null} last channel created, or null if none
|
|
190
|
+
*/
|
|
191
|
+
async function runSetupWizard(projectRoot, askFn) {
|
|
192
|
+
initIntakeDir(projectRoot);
|
|
193
|
+
process.stdout.write('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
|
194
|
+
process.stdout.write(' VP-INTAKE ► Setup Wizard\n');
|
|
195
|
+
process.stdout.write('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n');
|
|
196
|
+
|
|
197
|
+
let lastCreated = null;
|
|
198
|
+
|
|
199
|
+
// eslint-disable-next-line no-constant-condition
|
|
200
|
+
while (true) {
|
|
201
|
+
const ch = await wizardOneChannel(projectRoot, askFn);
|
|
202
|
+
if (ch) lastCreated = ch;
|
|
203
|
+
|
|
204
|
+
const more = await askFn('Cấu hình thêm channel khác?', [
|
|
205
|
+
{ label: 'Không — tiếp tục import', description: 'Chuyển sang chọn channel và import ticket' },
|
|
206
|
+
{ label: 'Có — thêm channel', description: 'Cấu hình thêm một nguồn ticket nữa' },
|
|
207
|
+
], false);
|
|
208
|
+
|
|
209
|
+
if (more !== 'Có — thêm channel') break;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return lastCreated;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = { runSetupWizard, slugify, extractSpreadsheetId };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viepilot",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.50.1",
|
|
4
4
|
"description": "**Autonomous Vibe Coding Framework / Bộ khung phát triển tự động có kiểm soát**",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -74,7 +74,8 @@
|
|
|
74
74
|
},
|
|
75
75
|
"dependencies": {
|
|
76
76
|
"docx": "^9.0.0",
|
|
77
|
-
"pptxgenjs": "^3.12.0"
|
|
77
|
+
"pptxgenjs": "^3.12.0",
|
|
78
|
+
"xlsx": "^0.18.5"
|
|
78
79
|
},
|
|
79
80
|
"optionalDependencies": {
|
|
80
81
|
"@googleapis/slides": "^1.0.0"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: vp-intake
|
|
3
3
|
description: "Import and triage tickets from Excel/M365 Online, Google Sheets, or CSV/TSV files — classify as BUG/ENH, accept/decline via AskUserQuestion, write back to source, generate TRIAGE report"
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
<greeting>
|
|
@@ -11,7 +11,7 @@ Output this banner as the **first** thing on every invocation — before questio
|
|
|
11
11
|
|
|
12
12
|
```
|
|
13
13
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
14
|
-
VIEPILOT ► VP-INTAKE v1.
|
|
14
|
+
VIEPILOT ► VP-INTAKE v1.1.0 (fw 2.50.0)
|
|
15
15
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
16
16
|
```
|
|
17
17
|
</greeting>
|
|
@@ -78,13 +78,16 @@ session report.
|
|
|
78
78
|
Optional flags:
|
|
79
79
|
- `--channel <id>` : Skip channel selection, use this channel ID directly
|
|
80
80
|
- `--dry-run` : Classify and show tickets without creating requests or writing back
|
|
81
|
+
- `--setup` : Force setup wizard — configure a new channel now (even if channels already exist)
|
|
82
|
+
- `--config` : Alias for `--setup`
|
|
81
83
|
|
|
82
84
|
**Supported channel types:**
|
|
83
85
|
| Type | Auth | Config field |
|
|
84
86
|
|------|------|-------------|
|
|
85
87
|
| `csv` | None | `path` (local file) |
|
|
86
88
|
| `google_sheets` | Service Account JSON | `spreadsheet_id` + `sheet_name` |
|
|
87
|
-
| `excel_m365` | Azure App Registration | `workbook_id` + `sheet_name` |
|
|
89
|
+
| `excel_m365` (Graph API) | Azure App Registration | `workbook_id` + `sheet_name` |
|
|
90
|
+
| `excel_m365` (sharing link) | None — anonymous | `sharing_url` |
|
|
88
91
|
|
|
89
92
|
**Config file:** `.viepilot/intake/channels.json`
|
|
90
93
|
**Credentials dir:** `.viepilot/.credentials/` (gitignored)
|
|
@@ -92,6 +95,36 @@ Optional flags:
|
|
|
92
95
|
|
|
93
96
|
<process>
|
|
94
97
|
|
|
98
|
+
### Step 0: Setup wizard detection (ENH-084)
|
|
99
|
+
|
|
100
|
+
**Check wizard trigger conditions:**
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
const args = parseArgs(VP_ARGS);
|
|
104
|
+
const forceSetup = args.includes('--setup') || args.includes('--config');
|
|
105
|
+
|
|
106
|
+
// Init dir + load channels
|
|
107
|
+
initIntakeDir(projectRoot); // lib/intake/channels.cjs
|
|
108
|
+
const { channels } = loadChannels(projectRoot);
|
|
109
|
+
const needsSetup = forceSetup || !hasRealChannels(channels); // lib/intake/channels.cjs
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**If `needsSetup` is true:**
|
|
113
|
+
|
|
114
|
+
Call `runSetupWizard(projectRoot, askFn)` from `lib/intake/setup-wizard.cjs`.
|
|
115
|
+
|
|
116
|
+
`askFn` wraps `AskUserQuestion` for structured option prompts, and falls back to free-text
|
|
117
|
+
input for open-ended fields (column names, file paths, URLs).
|
|
118
|
+
|
|
119
|
+
After wizard completes:
|
|
120
|
+
- Reload channels from channels.json
|
|
121
|
+
- If exactly 1 channel was just created → auto-select it and skip Step 3 (go to Step 4)
|
|
122
|
+
- Otherwise → continue to Step 3 (channel select AUQ with all available real channels)
|
|
123
|
+
|
|
124
|
+
**If `needsSetup` is false** and no `--setup` flag → skip Step 0 entirely, continue to Step 1.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
95
128
|
### Step 1: Init intake directory
|
|
96
129
|
|
|
97
130
|
```bash
|
|
@@ -102,13 +135,11 @@ This creates `.viepilot/intake/channels.json` (scaffold) and `.viepilot/.credent
|
|
|
102
135
|
|
|
103
136
|
### Step 2: Load channels
|
|
104
137
|
|
|
105
|
-
Read `.viepilot/intake/channels.json`. If no channels
|
|
106
|
-
tell the user:
|
|
138
|
+
Read `.viepilot/intake/channels.json`. If no real channels exist after Step 0:
|
|
107
139
|
|
|
108
140
|
```
|
|
109
141
|
No channels configured yet.
|
|
110
|
-
|
|
111
|
-
Run vp-tools intake-init to see the config scaffold.
|
|
142
|
+
Run /vp-intake --setup to configure a channel interactively.
|
|
112
143
|
```
|
|
113
144
|
|
|
114
145
|
### Step 3: Select channel (AUQ single-select)
|
|
@@ -188,6 +219,8 @@ options:
|
|
|
188
219
|
</process>
|
|
189
220
|
|
|
190
221
|
<success_criteria>
|
|
222
|
+
- [ ] Setup wizard triggers automatically when no real channels exist (or --setup flag)
|
|
223
|
+
- [ ] Wizard collects channel config via AUQ and writes to channels.json
|
|
191
224
|
- [ ] Channels loaded from `.viepilot/intake/channels.json`
|
|
192
225
|
- [ ] Correct adapter dispatched based on channel type
|
|
193
226
|
- [ ] Tickets classified as BUG / ENH / UNCLEAR
|
|
@@ -211,6 +244,7 @@ options:
|
|
|
211
244
|
| GitHub Copilot | ✅ Text fallback | Via `.agent.md` |
|
|
212
245
|
|
|
213
246
|
**Prompts in this skill:**
|
|
247
|
+
- Setup wizard: channel type, display name, field config, preview+confirm (Step 0)
|
|
214
248
|
- Channel selection (Step 3)
|
|
215
249
|
- Ticket accept/decline multi-select (Step 5)
|
|
216
250
|
- Decline reason (Step 5)
|
|
@@ -218,6 +252,8 @@ options:
|
|
|
218
252
|
- Next action (Step 8)
|
|
219
253
|
|
|
220
254
|
## Capabilities
|
|
255
|
+
- **Setup wizard** (`--setup`): AUQ-driven channel configuration — writes directly to channels.json
|
|
256
|
+
- Read tickets from SharePoint sharing links (anonymous WOPI download, no credentials)
|
|
221
257
|
- Read tickets from Excel/Microsoft 365 Online via Microsoft Graph API
|
|
222
258
|
- Read tickets from Google Sheets via Sheets API v4
|
|
223
259
|
- Read tickets from local CSV/TSV files
|