viepilot 2.49.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,36 @@ 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
+
10
40
  ## [2.49.0] - 2026-05-17
11
41
 
12
42
  ### Added
@@ -79,35 +79,55 @@ function httpsGet(url, headers) {
79
79
  });
80
80
  }
81
81
 
82
- /** Follow redirects and return {status, headers, body} */
83
- function httpsGetRaw(url, headers = {}, maxRedirects = 6) {
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) {
84
88
  return new Promise((resolve, reject) => {
85
- function doGet(currentUrl, remaining) {
89
+ function doGet(currentUrl, remaining, accCookies) {
86
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
+
87
97
  const req = https.request({
88
98
  hostname: urlObj.hostname,
89
99
  path: urlObj.pathname + urlObj.search,
90
100
  method: 'GET',
91
- headers: {
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
- },
101
+ headers: reqHeaders,
95
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
+
96
109
  if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && remaining > 0) {
97
110
  let nextUrl = res.headers.location;
98
111
  if (!nextUrl.startsWith('http')) {
99
112
  nextUrl = `${urlObj.protocol}//${urlObj.host}${nextUrl}`;
100
113
  }
101
- return doGet(nextUrl, remaining - 1);
114
+ // Drain response body before following redirect
115
+ res.resume();
116
+ return doGet(nextUrl, remaining - 1, newCookies);
102
117
  }
103
118
  const chunks = [];
104
119
  res.on('data', (chunk) => chunks.push(chunk));
105
- res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
120
+ res.on('end', () => resolve({
121
+ status: res.statusCode,
122
+ headers: res.headers,
123
+ body: Buffer.concat(chunks),
124
+ cookies: newCookies,
125
+ }));
106
126
  });
107
127
  req.on('error', reject);
108
128
  req.end();
109
129
  }
110
- doGet(url, maxRedirects);
130
+ doGet(url, maxRedirects, '');
111
131
  });
112
132
  }
113
133
 
@@ -177,12 +197,13 @@ function isSharingLink(url) {
177
197
  }
178
198
 
179
199
  /**
180
- * Resolve a SharePoint anonymous sharing link to a direct download URL.
200
+ * Resolve a SharePoint anonymous sharing link to {downloadUrl, cookies}.
181
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).
182
204
  */
183
205
  async function resolveSharePointDownloadUrl(sharingUrl) {
184
- // First request: follow redirects to the WOPI viewer page
185
- const { status, body } = await httpsGetRaw(sharingUrl);
206
+ const { status, body, cookies } = await httpsGetRaw(sharingUrl);
186
207
  if (status !== 200) {
187
208
  throw new Error(`SharePoint sharing link returned HTTP ${status}. The link may be expired or restricted.`);
188
209
  }
@@ -195,19 +216,35 @@ async function resolveSharePointDownloadUrl(sharingUrl) {
195
216
  throw new Error('Could not extract download URL from SharePoint viewer page. The file may require sign-in.');
196
217
  }
197
218
 
198
- // Unescape JSON unicode escapes (\\u0026 → &)
199
- return match[1].replace(/\\u([\da-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
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 };
200
222
  }
201
223
 
202
- /** Download xlsx bytes from a URL (no auth, temp token already embedded) */
203
- async function downloadXlsx(downloadUrl) {
204
- const { status, body } = await httpsGetRaw(downloadUrl, {
205
- Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,*/*',
206
- });
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);
207
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
+ }
208
237
  throw new Error(`Failed to download xlsx: HTTP ${status}`);
209
238
  }
210
- return body; // Buffer
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;
211
248
  }
212
249
 
213
250
  // ─── xlsx parser (SheetJS) ────────────────────────────────────────────────────
@@ -249,8 +286,8 @@ function parseXlsxBuffer(buffer, sheetName) {
249
286
 
250
287
  async function readViaSharingLink(channel) {
251
288
  const sharingUrl = channel.sharing_url;
252
- const downloadUrl = await resolveSharePointDownloadUrl(sharingUrl);
253
- const xlsxBuffer = await downloadXlsx(downloadUrl);
289
+ const { downloadUrl, cookies } = await resolveSharePointDownloadUrl(sharingUrl);
290
+ const xlsxBuffer = await downloadXlsx(downloadUrl, cookies);
254
291
  const rows = parseXlsxBuffer(xlsxBuffer, channel.sheet_name || null);
255
292
 
256
293
  if (!rows || rows.length === 0) return [];
@@ -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
+ };
@@ -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.49.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": {
@@ -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