@pacaf/wizard-ux 3.0.1 → 3.0.3

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/dist/index.html CHANGED
@@ -28,7 +28,7 @@
28
28
  }
29
29
  @keyframes spin { to { transform: rotate(360deg); } }
30
30
  </style>
31
- <script type="module" crossorigin src="./assets/index-BVelUveV.js"></script>
31
+ <script type="module" crossorigin src="/assets/index-zSgX3W7E.js"></script>
32
32
  </head>
33
33
  <body>
34
34
  <div id="root"><div id="boot"><div class="ring"></div></div></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pacaf/wizard-ux",
3
- "version": "3.0.1",
3
+ "version": "3.0.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Browser-based setup wizard for Power Apps Code Apps (parallel to @pacaf/wizard CLI).",
@@ -38,7 +38,7 @@
38
38
  "react-dom": "^19.0.0",
39
39
  "react-resizable-panels": "^2.1.7",
40
40
  "react-router-dom": "^7.1.0",
41
- "@pacaf/wizard": "3.0.1"
41
+ "@pacaf/wizard": "3.1.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/react": "^19.0.0",
@@ -7,6 +7,11 @@ const STEP3_PATH = resolve(__dirname, '..', 'steps', '03-app-registration.mjs');
7
7
  const step3 = await import(pathToFileURL(STEP3_PATH).href);
8
8
 
9
9
  export default async function onepasswordRoutes(fastify, opts) {
10
+ fastify.get('/vaults', async () => {
11
+ const vaults = step3.listOpVaults();
12
+ return { vaults };
13
+ });
14
+
10
15
  fastify.get('/items', async (req) => {
11
16
  const vault = String(req.query.vault || '').trim();
12
17
  if (!vault) return { items: [] };
@@ -65,7 +65,7 @@ function listOpItems(vaultName) {
65
65
  }
66
66
 
67
67
  // Exported for the API route to call
68
- export { listOpItems };
68
+ export { listOpItems, listOpVaults };
69
69
 
70
70
  function createOpVault(log, name) {
71
71
  const result = runSafe('op', ['vault', 'create', name, '--format=json']);
@@ -145,10 +145,6 @@ export default {
145
145
  const hasOp = hasCommand('op') || state.HAS_OP === true;
146
146
  const currentAuthType = state.AUTH_PROFILE_TYPE || 'user';
147
147
 
148
- // ── 1Password vault/item discovery ──
149
- const vaults = hasOp ? listOpVaults() : [];
150
- const defaultVault = state.OP_VAULT || vaults[0]?.value || '';
151
-
152
148
  return [
153
149
  {
154
150
  id: 'AUTH_PROFILE_TYPE',
@@ -170,23 +166,24 @@ export default {
170
166
  help: hasOp
171
167
  ? 'Store environment URLs and credentials as 1Password references. Secrets never touch disk.'
172
168
  : '1Password CLI was not detected. Leave this off unless op is available in your shell.',
173
- defaultValue: hasOp && state.AUTH_MODE === '1password',
169
+ defaultValue: state.USE_1PASSWORD === true || (hasOp && state.AUTH_MODE === '1password'),
174
170
  },
175
171
  {
176
172
  id: 'OP_VAULT',
177
173
  type: 'select',
178
174
  label: '1Password vault',
179
- help: vaults.length > 0
180
- ? 'Select an existing vault, create a new one, or enter a name manually.'
181
- : 'Could not discover vaults (1Password may be locked or offline). If you just authenticated to 1Password, refresh this page to reload the vault list. Otherwise, enter a vault name manually or create a new one.',
182
- defaultValue: state.OP_VAULT && vaults.some((v) => v.value === state.OP_VAULT)
183
- ? state.OP_VAULT
184
- : (vaults[0]?.value || ENTER_MANUALLY),
175
+ help: 'Select an existing vault, create a new one, or enter a name manually. Vaults load when 1Password is enabled — if the list is empty, ensure the op CLI is signed in (`op signin`) and toggle 1Password off and on to retry.',
176
+ defaultValue: state.OP_VAULT || ENTER_MANUALLY,
185
177
  options: [
186
- ...vaults,
187
178
  { value: ENTER_MANUALLY, label: 'Enter vault name manually' },
188
179
  { value: CREATE_NEW_VAULT, label: '+ Create new vault' },
189
180
  ],
181
+ dynamicOptions: {
182
+ endpoint: '/api/1password/vaults',
183
+ param: 'enabled',
184
+ dependsOn: 'USE_1PASSWORD',
185
+ responseKey: 'vaults',
186
+ },
190
187
  hideIf: { id: 'USE_1PASSWORD', equals: false },
191
188
  },
192
189
  {
@@ -1,5 +1,7 @@
1
1
  // Step 5 — Solution & Publisher (solution-first, publisher auto-resolved).
2
- import { dirname, resolve } from 'node:path';
2
+ import { dirname, resolve, join } from 'node:path';
3
+ import { writeFileSync, unlinkSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
3
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
4
6
  import { dvGet, dvPost, hasUsableSecret, setSecret, clearSecret } from '../lib/dataverse-bridge.mjs';
5
7
 
@@ -12,6 +14,21 @@ const PAC_TARGET = await import(pathToFileURL(resolve(ROOT_DIR, 'wizard', 'lib',
12
14
  const CREATE_NEW = '__create_new__';
13
15
  const PASTE_URL = '__paste_url__';
14
16
 
17
+ /**
18
+ * Run `pac env fetch` using --xmlFile instead of --xml to avoid XmlException
19
+ * on macOS PAC CLI 2.2.1+ where inline FetchXML attribute quotes get corrupted.
20
+ * Writes the XML to a temp file, runs the command, and cleans up.
21
+ */
22
+ function pacEnvFetchXml(pac, fetchXml, opts) {
23
+ const tmp = join(tmpdir(), `pac-fetch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.xml`);
24
+ try {
25
+ writeFileSync(tmp, fetchXml, 'utf-8');
26
+ return SHELL.runSafe(pac, ['env', 'fetch', '--xmlFile', tmp], opts);
27
+ } finally {
28
+ try { unlinkSync(tmp); } catch { /* best effort cleanup */ }
29
+ }
30
+ }
31
+
15
32
  // ─── Helpers ─────────────────────────────────────────────────────────
16
33
 
17
34
  /** Get the Maker Portal solutions deep-link via `pac org who`. */
@@ -22,7 +39,7 @@ function getMakerPortalLink() {
22
39
  if (whoOut) {
23
40
  const whoInfo = PAC_TARGET.parsePacOrgWho(whoOut);
24
41
  if (whoInfo.environmentId) {
25
- return `https://make.powerapps.com/e/${whoInfo.environmentId}/solutions`;
42
+ return `https://make.powerapps.com/environments/${whoInfo.environmentId}/solutions`;
26
43
  }
27
44
  }
28
45
  } catch { /* fall through */ }
@@ -36,58 +53,104 @@ function extractSolutionIdFromUrl(url) {
36
53
  }
37
54
 
38
55
  /**
39
- * Use `pac env fetch` to get solution + publisher by solution ID.
40
- * Works with both user auth and SPN uses whatever PAC profile is active.
41
- * Returns { solutionid, uniquename, friendlyname, version, publisherid, prefix, publisherFriendlyName, publisherUniqueName }
56
+ * Parse PAC CLI column-aligned tabular output into an array of row objects.
57
+ * Finds the header line (containing known column keywords), determines column
58
+ * boundaries from header token positions, and slices each subsequent data line
59
+ * by those boundaries. Handles multi-word values (e.g. "Climb Tracker") and
60
+ * aliased columns (e.g. "pub.customizationprefix").
61
+ */
62
+ function parsePacTabularRows(output, headerHints) {
63
+ const allLines = output.split(/\r?\n/);
64
+ const hints = headerHints || ['uniquename', 'solutionid', 'friendlyname'];
65
+
66
+ // Find the header line — first line containing at least one of the hints.
67
+ let headerIdx = -1;
68
+ for (let i = 0; i < allLines.length; i++) {
69
+ const lower = allLines[i].toLowerCase();
70
+ if (hints.some((h) => lower.includes(h))) {
71
+ headerIdx = i;
72
+ break;
73
+ }
74
+ }
75
+ if (headerIdx < 0) return [];
76
+
77
+ const headerLine = allLines[headerIdx];
78
+
79
+ // Extract column names and their start positions from the header.
80
+ const cols = [];
81
+ const re = /\S+/g;
82
+ let m;
83
+ while ((m = re.exec(headerLine)) !== null) {
84
+ cols.push({ name: m[0].toLowerCase(), start: m.index });
85
+ }
86
+
87
+ // Parse each data line after the header.
88
+ const rows = [];
89
+ for (let i = headerIdx + 1; i < allLines.length; i++) {
90
+ const line = allLines[i];
91
+ if (!line.trim()) continue;
92
+ if (/^(Connected|Microsoft|Version:|Online|Feedback)/i.test(line.trim())) continue;
93
+ const row = {};
94
+ for (let c = 0; c < cols.length; c++) {
95
+ const start = cols[c].start;
96
+ const end = c < cols.length - 1 ? cols[c + 1].start : line.length;
97
+ row[cols[c].name] = (start < line.length ? line.slice(start, end) : '').trim();
98
+ }
99
+ if (Object.values(row).some((v) => v)) rows.push(row);
100
+ }
101
+ return rows;
102
+ }
103
+
104
+ /**
105
+ * Fetch solution + publisher by solution ID using a single joined FetchXML.
106
+ * Uses <link-entity> to include publisher columns directly, avoiding the
107
+ * two-query approach that broke when publisherid rendered as a display name.
42
108
  */
43
109
  function fetchSolutionViaPac(solutionId) {
44
110
  const pac = SHELL.pacPath();
45
111
  if (!pac) return null;
46
- // Use separate queries: one for solution, one for publisher (avoids tabular parse issues)
47
- const solXml = `<fetch><entity name="solution"><attribute name="uniquename"/><attribute name="friendlyname"/><attribute name="version"/><attribute name="solutionid"/><attribute name="publisherid"/><filter><condition attribute="solutionid" operator="eq" value="${solutionId}"/></filter></entity></fetch>`;
48
- const solOutput = SHELL.runSafe(pac, ['env', 'fetch', '--xml', solXml]);
49
- if (!solOutput) return null;
50
-
51
- // Parse the tabular output for the solution
52
- const solId = extractGuid(solOutput, solutionId) || solutionId;
53
- const version = extractPattern(solOutput, /(\d+\.\d+\.\d+\.\d+)/);
54
-
55
- // The publisherid in the solution output is a GUID (the lookup value)
56
- const guids = [...solOutput.matchAll(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi)]
57
- .map((m) => m[0].toLowerCase());
58
- // publisherid is the GUID that is NOT the solutionid
59
- const pubGuid = guids.find((g) => g !== solId) || '';
60
-
61
- // Extract unique name: it's a single PascalCase/camelCase token (no spaces, not a GUID, not a version)
62
- const tokens = extractDataTokens(solOutput);
63
- const uniquename = tokens.find((t) => !isGuid(t) && !/^\d+\.\d+/.test(t) && t.length > 1) || '';
64
-
65
- // For friendlyname, we need to be smarter. The text between known tokens.
66
- // Approach: remove GUIDs, version, uniquename, and "Connected..." lines → what's left is the friendly name
67
- const friendlyname = extractFriendlyName(solOutput, [solId, pubGuid, version, uniquename]);
68
-
69
- // Now fetch the publisher details using the pubGuid
70
- let prefix = '';
71
- let publisherFriendlyName = '';
72
- let publisherUniqueName = '';
73
- let choiceValuePrefix = '';
74
-
75
- if (pubGuid) {
76
- const pubXml = `<fetch><entity name="publisher"><attribute name="customizationprefix"/><attribute name="friendlyname"/><attribute name="uniquename"/><attribute name="publisherid"/><attribute name="customizationoptionvalueprefix"/><filter><condition attribute="publisherid" operator="eq" value="${pubGuid}"/></filter></entity></fetch>`;
77
- const pubOutput = SHELL.runSafe(pac, ['env', 'fetch', '--xml', pubXml]);
78
- if (pubOutput) {
79
- const pubTokens = extractDataTokens(pubOutput);
80
- // customizationprefix is a short lowercase string
81
- prefix = pubTokens.find((t) => /^[a-z]{2,8}$/.test(t) && !['and', 'for', 'the'].includes(t)) || '';
82
- // customizationoptionvalueprefix is a numeric string
83
- choiceValuePrefix = pubTokens.find((t) => /^\d{3,6}$/.test(t)) || '';
84
- // publisher uniquename is a single token (not GUID, not prefix, not choiceValuePrefix)
85
- publisherUniqueName = pubTokens.find((t) =>
86
- !isGuid(t) && t !== prefix && t !== choiceValuePrefix && t.length > 1 && !/^\d+$/.test(t),
87
- ) || '';
88
- publisherFriendlyName = extractFriendlyName(pubOutput, [pubGuid, prefix, publisherUniqueName, choiceValuePrefix]);
89
- }
90
- }
112
+
113
+ const xml = [
114
+ '<fetch>',
115
+ ' <entity name="solution">',
116
+ ' <attribute name="uniquename"/>',
117
+ ' <attribute name="friendlyname"/>',
118
+ ' <attribute name="version"/>',
119
+ ' <attribute name="solutionid"/>',
120
+ ' <link-entity name="publisher" from="publisherid" to="publisherid" link-type="inner" alias="pub">',
121
+ ' <attribute name="customizationprefix"/>',
122
+ ' <attribute name="uniquename"/>',
123
+ ' <attribute name="friendlyname"/>',
124
+ ' <attribute name="publisherid"/>',
125
+ ' <attribute name="customizationoptionvalueprefix"/>',
126
+ ' </link-entity>',
127
+ ' <filter>',
128
+ ` <condition attribute="solutionid" operator="eq" value="${solutionId}"/>`,
129
+ ' </filter>',
130
+ ' </entity>',
131
+ '</fetch>',
132
+ ].join('\n');
133
+
134
+ const output = pacEnvFetchXml(pac, xml);
135
+ if (!output) return null;
136
+
137
+ const rows = parsePacTabularRows(output, [
138
+ 'uniquename', 'friendlyname', 'solutionid', 'pub.customizationprefix',
139
+ ]);
140
+ if (rows.length === 0) return null;
141
+
142
+ const row = rows[0];
143
+
144
+ // Column names come back lowercased. Aliased columns use "pub.<attr>".
145
+ const solId = (row['solutionid'] || solutionId).toLowerCase();
146
+ const uniquename = row['uniquename'] || '';
147
+ const friendlyname = row['friendlyname'] || '';
148
+ const version = row['version'] || '';
149
+ const prefix = row['pub.customizationprefix'] || '';
150
+ const pubGuid = row['pub.publisherid'] || '';
151
+ const publisherUniqueName = row['pub.uniquename'] || '';
152
+ const publisherFriendlyName = row['pub.friendlyname'] || '';
153
+ const choiceValuePrefix = row['pub.customizationoptionvalueprefix'] || '';
91
154
 
92
155
  return {
93
156
  solutionid: solId,
@@ -103,130 +166,59 @@ function fetchSolutionViaPac(solutionId) {
103
166
  }
104
167
 
105
168
  /**
106
- * List unmanaged solutions via `pac env fetch`. Returns basic solution list.
107
- * Each entry has { solutionid, label }.
169
+ * List unmanaged solutions via a single `pac env fetch` with a publisher join.
170
+ * Returns [{ value: solutionid, label: "Display Name (prefix_)" }].
171
+ * Uses parsePacTabularRows to handle multi-word names correctly and avoids
172
+ * the N+1 per-solution fetch loop.
108
173
  */
109
174
  function listSolutionSummariesViaPac() {
110
175
  const pac = SHELL.pacPath();
111
176
  if (!pac) return [];
112
- const xml = `<fetch><entity name="solution"><attribute name="uniquename"/><attribute name="friendlyname"/><attribute name="solutionid"/><link-entity name="publisher" from="publisherid" to="publisherid" link-type="inner" alias="pub"><attribute name="customizationprefix"/></link-entity><filter><condition attribute="ismanaged" operator="eq" value="0"/><condition attribute="isvisible" operator="eq" value="1"/></filter><order attribute="friendlyname"/></entity></fetch>`;
113
- const output = SHELL.runSafe(pac, ['env', 'fetch', '--xml', xml], { timeout: 15000 });
177
+
178
+ const xml = [
179
+ '<fetch>',
180
+ ' <entity name="solution">',
181
+ ' <attribute name="uniquename"/>',
182
+ ' <attribute name="friendlyname"/>',
183
+ ' <attribute name="solutionid"/>',
184
+ ' <link-entity name="publisher" from="publisherid" to="publisherid" link-type="inner" alias="pub">',
185
+ ' <attribute name="customizationprefix"/>',
186
+ ' <attribute name="friendlyname"/>',
187
+ ' </link-entity>',
188
+ ' <filter>',
189
+ ' <condition attribute="ismanaged" operator="eq" value="0"/>',
190
+ ' <condition attribute="isvisible" operator="eq" value="1"/>',
191
+ ' </filter>',
192
+ ' <order attribute="friendlyname"/>',
193
+ ' </entity>',
194
+ '</fetch>',
195
+ ].join('\n');
196
+
197
+ const output = pacEnvFetchXml(pac, xml, { timeout: 15000 });
114
198
  if (!output) return [];
115
199
 
116
- // Parse each data row. The output has: uniquename, friendlyname, solutionid, pub.customizationprefix
117
- // Each row has a GUID. Group by GUID.
118
- const guids = [...output.matchAll(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi)]
119
- .map((m) => m[1].toLowerCase());
120
- const uniqueGuids = [...new Set(guids)];
200
+ const rows = parsePacTabularRows(output, [
201
+ 'uniquename', 'friendlyname', 'solutionid', 'pub.customizationprefix',
202
+ ]);
121
203
 
122
- // Filter out well-known system solution GUIDs
204
+ // Filter out system solutions.
123
205
  const systemGuids = new Set([
124
206
  'fd140aaf-4df4-11dd-bd17-0019b9312238', // Default Solution
125
207
  '00000001-0000-0000-0001-00000000009b', // Common Data Services
126
208
  ]);
127
-
128
- // For each solution GUID, we need the display label.
129
- // Since parsing multi-row tabular output is fragile, use a simple approach:
130
- // split output into segments by GUID, extract the text around each.
131
- const lines = output.split(/\r?\n/).filter((l) =>
132
- l.trim() &&
133
- !l.startsWith('Connected') &&
134
- !l.startsWith('Microsoft') &&
135
- !l.startsWith('Version:') &&
136
- !l.startsWith('Online') &&
137
- !l.startsWith('Feedback'),
138
- );
139
-
140
- // The header line contains column names. Skip it.
141
- const headerKeywords = ['uniquename', 'friendlyname', 'solutionid'];
142
- const dataLines = lines.filter((l) => !headerKeywords.some((kw) => l.toLowerCase().includes(kw)));
143
- const fullData = dataLines.join(' ');
144
-
145
- const results = [];
146
- for (const guid of uniqueGuids) {
147
- if (systemGuids.has(guid)) continue;
148
-
149
- // Find the text around this GUID to extract a label
150
- const idx = fullData.indexOf(guid);
151
- if (idx < 0) continue;
152
-
153
- // Look for a short lowercase prefix near the GUID (the pub.customizationprefix)
154
- const nearby = fullData.slice(Math.max(0, idx - 200), idx + 200);
155
- const prefixMatch = nearby.match(/\b([a-z]{2,8})\b/);
156
- const prefix = prefixMatch ? prefixMatch[1] : '?';
157
-
158
- // The friendlyname is harder to extract from the concatenated data.
159
- // We'll use the GUID as the value and fetch full details on selection.
160
- results.push({ value: guid, label: `Solution ${guid.slice(0, 8)}… (${prefix}_)` });
161
- }
162
-
163
- // If we got results but labels are poor, try fetching details for each
164
- // (only if there are few solutions)
165
- if (results.length > 0 && results.length <= 20) {
166
- for (const r of results) {
167
- const details = fetchSolutionViaPac(r.value);
168
- if (details) {
169
- const name = details.friendlyname || details.uniquename || r.value;
170
- r.label = `${name} (${details.prefix || '?'}_)`;
171
- }
172
- }
173
- }
174
-
175
- return results.filter((r) => r.label && !r.label.includes('Default Solution') && !r.label.includes('Common Data'));
176
- }
177
-
178
- // ─── Parse utilities ─────────────────────────────────────────────────
179
-
180
- function isGuid(s) {
181
- return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
182
- }
183
-
184
- function extractGuid(text, hint) {
185
- const guids = [...text.matchAll(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi)]
186
- .map((m) => m[0].toLowerCase());
187
- if (hint) return guids.find((g) => g === hint.toLowerCase()) || guids[0] || '';
188
- return guids[0] || '';
189
- }
190
-
191
- function extractPattern(text, re) {
192
- const m = text.match(re);
193
- return m ? m[1] : '';
194
- }
195
-
196
- /** Extract data tokens from PAC CLI output (skip header/banner lines). */
197
- function extractDataTokens(output) {
198
- const lines = output.split(/\r?\n/).filter((l) =>
199
- l.trim() &&
200
- !l.startsWith('Connected') &&
201
- !l.startsWith('Microsoft') &&
202
- !l.startsWith('Version:') &&
203
- !l.startsWith('Online') &&
204
- !l.startsWith('Feedback'),
205
- );
206
- const headerKeywords = ['uniquename', 'friendlyname', 'solutionid', 'publisherid', 'customizationprefix', 'version'];
207
- const dataLines = lines.filter((l) => !headerKeywords.some((kw) => l.toLowerCase().includes(kw)));
208
- return dataLines.join(' ').split(/\s+/).filter(Boolean);
209
- }
210
-
211
- /** Extract the friendly name from PAC output by removing known tokens. */
212
- function extractFriendlyName(output, knownTokens) {
213
- const lines = output.split(/\r?\n/).filter((l) =>
214
- l.trim() &&
215
- !l.startsWith('Connected') &&
216
- !l.startsWith('Microsoft') &&
217
- !l.startsWith('Version:') &&
218
- !l.startsWith('Online') &&
219
- !l.startsWith('Feedback'),
220
- );
221
- const headerKeywords = ['uniquename', 'friendlyname', 'solutionid', 'publisherid', 'customizationprefix', 'version', 'customizationoptionvalueprefix'];
222
- const dataLines = lines.filter((l) => !headerKeywords.some((kw) => l.toLowerCase().includes(kw)));
223
- let text = dataLines.join(' ');
224
- // Remove known tokens (GUIDs, version, uniquename, prefix)
225
- for (const token of knownTokens) {
226
- if (token) text = text.replace(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), '');
227
- }
228
- // Clean up multiple spaces and trim
229
- return text.replace(/\s+/g, ' ').trim();
209
+ const systemNames = ['default', 'common data services default solution'];
210
+
211
+ return rows
212
+ .filter((r) => {
213
+ const id = (r['solutionid'] || '').toLowerCase();
214
+ const name = (r['friendlyname'] || '').toLowerCase();
215
+ return id && !systemGuids.has(id) && !systemNames.includes(name);
216
+ })
217
+ .map((r) => {
218
+ const name = r['friendlyname'] || r['uniquename'] || r['solutionid'];
219
+ const prefix = r['pub.customizationprefix'] || '?';
220
+ return { value: r['solutionid'].toLowerCase(), label: `${name} (${prefix}_)` };
221
+ });
230
222
  }
231
223
 
232
224
  // ─── Dataverse API helpers (SPN) ─────────────────────────────────────
@@ -350,7 +342,7 @@ export default {
350
342
  '3. Click on the solution to open it',
351
343
  '4. Copy the URL from your browser\'s address bar',
352
344
  '5. Paste it here — it looks like:',
353
- ' https://make.powerapps.com/e/.../solutions/{guid}',
345
+ ' https://make.powerapps.com/environments/.../solutions/{guid}',
354
346
  ].join('\n'),
355
347
  });
356
348
 
@@ -450,7 +442,8 @@ export default {
450
442
 
451
443
  // ── Paste URL → extract GUID → fetch via PAC ──
452
444
  if (selection === PASTE_URL) {
453
- const url = String(answers.SOLUTION_URL || '').trim();
445
+ // Strip trailing punctuation that users may accidentally include when pasting from a document.
446
+ const url = String(answers.SOLUTION_URL || '').trim().replace(/[.,;:!?]+$/, '');
454
447
  const solutionId = extractSolutionIdFromUrl(url);
455
448
  if (!solutionId) throw new Error('Could not find a solution GUID in that URL. Paste the full Maker Portal solution URL (…/solutions/{guid}).');
456
449
 
@@ -23,8 +23,8 @@ function runCommand(log, command, opts = {}) {
23
23
  return new Promise((resolvePromise) => {
24
24
  log.info(`$ ${command}`);
25
25
  const child = process.platform === 'win32'
26
- ? spawn(process.env.COMSPEC || 'cmd.exe', ['/d', '/s', '/c', command], { cwd: opts.cwd || ROOT_DIR, stdio: ['ignore', 'pipe', 'pipe'] })
27
- : spawn('sh', ['-c', command], { cwd: opts.cwd || ROOT_DIR, stdio: ['ignore', 'pipe', 'pipe'] });
26
+ ? spawn(process.env.COMSPEC || 'cmd.exe', ['/d', '/s', '/c', command], { cwd: opts.cwd || process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] })
27
+ : spawn('sh', ['-c', command], { cwd: opts.cwd || process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] });
28
28
  child.stdout.setEncoding('utf-8');
29
29
  child.stderr.setEncoding('utf-8');
30
30
  child.stdout.on('data', (chunk) => log.info(String(chunk).trimEnd()));
@@ -40,7 +40,7 @@ function runCommand(log, command, opts = {}) {
40
40
  function runFile(log, file, args, opts = {}) {
41
41
  return new Promise((resolvePromise) => {
42
42
  log.info(`$ ${SHELL.formatCommandForLog(file, args)}`);
43
- const child = SHELL.spawnSafe(file, args, { cwd: opts.cwd || ROOT_DIR, stdio: ['ignore', 'pipe', 'pipe'] });
43
+ const child = SHELL.spawnSafe(file, args, { cwd: opts.cwd || process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] });
44
44
  child.stdout.setEncoding('utf-8');
45
45
  child.stderr.setEncoding('utf-8');
46
46
  child.stdout.on('data', (chunk) => log.info(String(chunk).trimEnd()));
@@ -68,7 +68,7 @@ function hasCommand(name) {
68
68
 
69
69
  function resolveCredentialValues(state) {
70
70
  return PAC_TARGET.resolveCredentialValues({
71
- rootDir: ROOT_DIR,
71
+ rootDir: state.PROJECT_DIR || process.cwd(),
72
72
  opBin: process.env.OP_BIN || (hasCommand('op') ? 'op' : null),
73
73
  source: state.AUTH_MODE || 'auto',
74
74
  });
@@ -77,7 +77,7 @@ function resolveCredentialValues(state) {
77
77
  function verifyPacTarget({ pac, projectDir, state, credentialValues, profileType, requirePowerConfig, requirePowerConfigTarget }) {
78
78
  return PAC_TARGET.selectAndVerifyPacProfile({
79
79
  pac,
80
- rootDir: ROOT_DIR,
80
+ rootDir: projectDir || process.cwd(),
81
81
  wizardState: {
82
82
  WIZARD_TARGET_ENV: state.WIZARD_TARGET_ENV || 'dev',
83
83
  PP_ENV_DEV: state.PP_ENV_DEV || '',
@@ -148,7 +148,7 @@ export default {
148
148
  },
149
149
 
150
150
  questions(state) {
151
- const rootDefault = state.PROJECT_DIR || ROOT_DIR;
151
+ const rootDefault = state.PROJECT_DIR || process.cwd();
152
152
  const existingOrigin = SHELL.run('git remote get-url origin', { cwd: rootDefault }) || '';
153
153
  const needsRemote = !existingOrigin || /PAppsCAFoundations/i.test(existingOrigin);
154
154
  return [
@@ -194,7 +194,7 @@ export default {
194
194
 
195
195
  async apply(answers, state, log) {
196
196
  const appName = state.APP_NAME || 'Power Apps Code App';
197
- const projectDir = resolve(String(answers.PROJECT_DIR || ROOT_DIR).trim());
197
+ const projectDir = resolve(String(answers.PROJECT_DIR || process.cwd()).trim());
198
198
  const foundationLogger = makeFoundationLogger(log);
199
199
 
200
200
  if (existsSync(projectDir) && readdirSync(projectDir).length > 0 && answers.CONTINUE_NONEMPTY !== true) {
@@ -208,7 +208,7 @@ export default {
208
208
  log.info('Downloading starter template...');
209
209
  const templateArgs = ['--yes', 'degit', 'microsoft/PowerAppsCodeApps/templates/starter', projectDir];
210
210
  if (dirNotEmpty) templateArgs.push('--force');
211
- const templateOk = await runFile(log, toolCommand('npx'), templateArgs, { cwd: ROOT_DIR });
211
+ const templateOk = await runFile(log, toolCommand('npx'), templateArgs, { cwd: projectDir });
212
212
  if (!templateOk) {
213
213
  log.warn('Template download failed. Creating minimal project structure instead.');
214
214
  SCAFFOLD.createMinimalProject(projectDir, appName);