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 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 readExcelM365(channel, projectRoot) {
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
- const colMap = channel.column_map;
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 < response.values.length; i++) {
134
- const row = response.values[i];
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 = { readExcelM365, AuthRequiredError, clearTokenCache };
357
+ module.exports = {
358
+ readExcelM365,
359
+ AuthRequiredError,
360
+ clearTokenCache,
361
+ // exported for testing
362
+ isSharingLink,
363
+ resolveSharePointDownloadUrl,
364
+ parseXlsxBuffer,
365
+ };
@@ -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 a "workbook_id" field`);
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
- module.exports = { loadChannels, validateChannel, getChannelById, initIntakeDir, CHANNELS_SCAFFOLD };
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.48.0",
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.0.0
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.0.0 (fw 2.48.0)
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 configured (only example stubs remain),
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
- Edit .viepilot/intake/channels.json to add your ticket sources.
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