pax8-cta 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/LICENSE +200 -0
  2. package/README.md +659 -0
  3. package/demo-data/solutions/ProductQADemo_1_0_0_2_managed.zip +0 -0
  4. package/dist/commands/analyze.d.ts +28 -0
  5. package/dist/commands/analyze.d.ts.map +1 -0
  6. package/dist/commands/analyze.js +571 -0
  7. package/dist/commands/analyze.js.map +1 -0
  8. package/dist/commands/auth.d.ts +18 -0
  9. package/dist/commands/auth.d.ts.map +1 -0
  10. package/dist/commands/auth.js +171 -0
  11. package/dist/commands/auth.js.map +1 -0
  12. package/dist/commands/config.d.ts +34 -0
  13. package/dist/commands/config.d.ts.map +1 -0
  14. package/dist/commands/config.js +299 -0
  15. package/dist/commands/config.js.map +1 -0
  16. package/dist/commands/demo.d.ts +33 -0
  17. package/dist/commands/demo.d.ts.map +1 -0
  18. package/dist/commands/demo.js +362 -0
  19. package/dist/commands/demo.js.map +1 -0
  20. package/dist/commands/deploy.d.ts +18 -0
  21. package/dist/commands/deploy.d.ts.map +1 -0
  22. package/dist/commands/deploy.js +1186 -0
  23. package/dist/commands/deploy.js.map +1 -0
  24. package/dist/commands/deployments/helpers.d.ts +68 -0
  25. package/dist/commands/deployments/helpers.d.ts.map +1 -0
  26. package/dist/commands/deployments/helpers.js +414 -0
  27. package/dist/commands/deployments/helpers.js.map +1 -0
  28. package/dist/commands/deployments/index.d.ts +24 -0
  29. package/dist/commands/deployments/index.d.ts.map +1 -0
  30. package/dist/commands/deployments/index.js +47 -0
  31. package/dist/commands/deployments/index.js.map +1 -0
  32. package/dist/commands/deployments/list.d.ts +18 -0
  33. package/dist/commands/deployments/list.d.ts.map +1 -0
  34. package/dist/commands/deployments/list.js +97 -0
  35. package/dist/commands/deployments/list.js.map +1 -0
  36. package/dist/commands/deployments/show.d.ts +18 -0
  37. package/dist/commands/deployments/show.d.ts.map +1 -0
  38. package/dist/commands/deployments/show.js +81 -0
  39. package/dist/commands/deployments/show.js.map +1 -0
  40. package/dist/commands/deployments/undo.d.ts +18 -0
  41. package/dist/commands/deployments/undo.d.ts.map +1 -0
  42. package/dist/commands/deployments/undo.js +295 -0
  43. package/dist/commands/deployments/undo.js.map +1 -0
  44. package/dist/commands/export.d.ts +18 -0
  45. package/dist/commands/export.d.ts.map +1 -0
  46. package/dist/commands/export.js +133 -0
  47. package/dist/commands/export.js.map +1 -0
  48. package/dist/commands/import.d.ts +18 -0
  49. package/dist/commands/import.d.ts.map +1 -0
  50. package/dist/commands/import.js +129 -0
  51. package/dist/commands/import.js.map +1 -0
  52. package/dist/commands/init-config.d.ts +26 -0
  53. package/dist/commands/init-config.d.ts.map +1 -0
  54. package/dist/commands/init-config.js +123 -0
  55. package/dist/commands/init-config.js.map +1 -0
  56. package/dist/commands/init-validation.d.ts +47 -0
  57. package/dist/commands/init-validation.d.ts.map +1 -0
  58. package/dist/commands/init-validation.js +339 -0
  59. package/dist/commands/init-validation.js.map +1 -0
  60. package/dist/commands/init-wizard.d.ts +25 -0
  61. package/dist/commands/init-wizard.d.ts.map +1 -0
  62. package/dist/commands/init-wizard.js +433 -0
  63. package/dist/commands/init-wizard.js.map +1 -0
  64. package/dist/commands/init.d.ts +18 -0
  65. package/dist/commands/init.d.ts.map +1 -0
  66. package/dist/commands/init.js +46 -0
  67. package/dist/commands/init.js.map +1 -0
  68. package/dist/commands/resolve-url.d.ts +18 -0
  69. package/dist/commands/resolve-url.d.ts.map +1 -0
  70. package/dist/commands/resolve-url.js +126 -0
  71. package/dist/commands/resolve-url.js.map +1 -0
  72. package/dist/commands/setup.d.ts +18 -0
  73. package/dist/commands/setup.d.ts.map +1 -0
  74. package/dist/commands/setup.js +239 -0
  75. package/dist/commands/setup.js.map +1 -0
  76. package/dist/commands/solutions/drift-analysis.d.ts +73 -0
  77. package/dist/commands/solutions/drift-analysis.d.ts.map +1 -0
  78. package/dist/commands/solutions/drift-analysis.js +416 -0
  79. package/dist/commands/solutions/drift-analysis.js.map +1 -0
  80. package/dist/commands/solutions/drift.d.ts +32 -0
  81. package/dist/commands/solutions/drift.d.ts.map +1 -0
  82. package/dist/commands/solutions/drift.js +641 -0
  83. package/dist/commands/solutions/drift.js.map +1 -0
  84. package/dist/commands/solutions/fix-planner.d.ts +48 -0
  85. package/dist/commands/solutions/fix-planner.d.ts.map +1 -0
  86. package/dist/commands/solutions/fix-planner.js +43 -0
  87. package/dist/commands/solutions/fix-planner.js.map +1 -0
  88. package/dist/commands/solutions/helpers.d.ts +35 -0
  89. package/dist/commands/solutions/helpers.d.ts.map +1 -0
  90. package/dist/commands/solutions/helpers.js +54 -0
  91. package/dist/commands/solutions/helpers.js.map +1 -0
  92. package/dist/commands/solutions/index.d.ts +18 -0
  93. package/dist/commands/solutions/index.d.ts.map +1 -0
  94. package/dist/commands/solutions/index.js +30 -0
  95. package/dist/commands/solutions/index.js.map +1 -0
  96. package/dist/commands/solutions/list.d.ts +18 -0
  97. package/dist/commands/solutions/list.d.ts.map +1 -0
  98. package/dist/commands/solutions/list.js +174 -0
  99. package/dist/commands/solutions/list.js.map +1 -0
  100. package/dist/commands/solutions/remove.d.ts +18 -0
  101. package/dist/commands/solutions/remove.d.ts.map +1 -0
  102. package/dist/commands/solutions/remove.js +137 -0
  103. package/dist/commands/solutions/remove.js.map +1 -0
  104. package/dist/commands/solutions/risk-calculator.d.ts +33 -0
  105. package/dist/commands/solutions/risk-calculator.d.ts.map +1 -0
  106. package/dist/commands/solutions/risk-calculator.js +79 -0
  107. package/dist/commands/solutions/risk-calculator.js.map +1 -0
  108. package/dist/commands/solutions/show.d.ts +18 -0
  109. package/dist/commands/solutions/show.d.ts.map +1 -0
  110. package/dist/commands/solutions/show.js +165 -0
  111. package/dist/commands/solutions/show.js.map +1 -0
  112. package/dist/commands/status.d.ts +18 -0
  113. package/dist/commands/status.d.ts.map +1 -0
  114. package/dist/commands/status.js +573 -0
  115. package/dist/commands/status.js.map +1 -0
  116. package/dist/commands/telemetry.d.ts +18 -0
  117. package/dist/commands/telemetry.d.ts.map +1 -0
  118. package/dist/commands/telemetry.js +85 -0
  119. package/dist/commands/telemetry.js.map +1 -0
  120. package/dist/commands/tenants/health.d.ts +18 -0
  121. package/dist/commands/tenants/health.d.ts.map +1 -0
  122. package/dist/commands/tenants/health.js +172 -0
  123. package/dist/commands/tenants/health.js.map +1 -0
  124. package/dist/commands/tenants/helpers.d.ts +44 -0
  125. package/dist/commands/tenants/helpers.d.ts.map +1 -0
  126. package/dist/commands/tenants/helpers.js +72 -0
  127. package/dist/commands/tenants/helpers.js.map +1 -0
  128. package/dist/commands/tenants/index.d.ts +19 -0
  129. package/dist/commands/tenants/index.d.ts.map +1 -0
  130. package/dist/commands/tenants/index.js +39 -0
  131. package/dist/commands/tenants/index.js.map +1 -0
  132. package/dist/commands/tenants/inspect.d.ts +18 -0
  133. package/dist/commands/tenants/inspect.d.ts.map +1 -0
  134. package/dist/commands/tenants/inspect.js +176 -0
  135. package/dist/commands/tenants/inspect.js.map +1 -0
  136. package/dist/commands/tenants/list.d.ts +18 -0
  137. package/dist/commands/tenants/list.d.ts.map +1 -0
  138. package/dist/commands/tenants/list.js +144 -0
  139. package/dist/commands/tenants/list.js.map +1 -0
  140. package/dist/commands/tenants/manage.d.ts +20 -0
  141. package/dist/commands/tenants/manage.d.ts.map +1 -0
  142. package/dist/commands/tenants/manage.js +206 -0
  143. package/dist/commands/tenants/manage.js.map +1 -0
  144. package/dist/commands/tenants/show.d.ts +18 -0
  145. package/dist/commands/tenants/show.d.ts.map +1 -0
  146. package/dist/commands/tenants/show.js +191 -0
  147. package/dist/commands/tenants/show.js.map +1 -0
  148. package/dist/commands/validate.d.ts +18 -0
  149. package/dist/commands/validate.d.ts.map +1 -0
  150. package/dist/commands/validate.js +536 -0
  151. package/dist/commands/validate.js.map +1 -0
  152. package/dist/index.d.ts +19 -0
  153. package/dist/index.d.ts.map +1 -0
  154. package/dist/index.js +258 -0
  155. package/dist/index.js.map +1 -0
  156. package/dist/lib/auth.d.ts +51 -0
  157. package/dist/lib/auth.d.ts.map +1 -0
  158. package/dist/lib/auth.js +153 -0
  159. package/dist/lib/auth.js.map +1 -0
  160. package/dist/lib/banner.d.ts +19 -0
  161. package/dist/lib/banner.d.ts.map +1 -0
  162. package/dist/lib/banner.js +78 -0
  163. package/dist/lib/banner.js.map +1 -0
  164. package/dist/lib/command-wrapper.d.ts +56 -0
  165. package/dist/lib/command-wrapper.d.ts.map +1 -0
  166. package/dist/lib/command-wrapper.js +71 -0
  167. package/dist/lib/command-wrapper.js.map +1 -0
  168. package/dist/lib/credentials.d.ts +56 -0
  169. package/dist/lib/credentials.d.ts.map +1 -0
  170. package/dist/lib/credentials.js +146 -0
  171. package/dist/lib/credentials.js.map +1 -0
  172. package/dist/lib/demo-banner.d.ts +15 -0
  173. package/dist/lib/demo-banner.d.ts.map +1 -0
  174. package/dist/lib/demo-banner.js +33 -0
  175. package/dist/lib/demo-banner.js.map +1 -0
  176. package/dist/lib/error-handler.d.ts +51 -0
  177. package/dist/lib/error-handler.d.ts.map +1 -0
  178. package/dist/lib/error-handler.js +458 -0
  179. package/dist/lib/error-handler.js.map +1 -0
  180. package/dist/lib/errors.d.ts +61 -0
  181. package/dist/lib/errors.d.ts.map +1 -0
  182. package/dist/lib/errors.js +168 -0
  183. package/dist/lib/errors.js.map +1 -0
  184. package/dist/lib/formatters.d.ts +55 -0
  185. package/dist/lib/formatters.d.ts.map +1 -0
  186. package/dist/lib/formatters.js +163 -0
  187. package/dist/lib/formatters.js.map +1 -0
  188. package/dist/lib/graph-client.d.ts +74 -0
  189. package/dist/lib/graph-client.d.ts.map +1 -0
  190. package/dist/lib/graph-client.js +231 -0
  191. package/dist/lib/graph-client.js.map +1 -0
  192. package/dist/lib/input.d.ts +22 -0
  193. package/dist/lib/input.d.ts.map +1 -0
  194. package/dist/lib/input.js +120 -0
  195. package/dist/lib/input.js.map +1 -0
  196. package/dist/lib/interactive-wizard.d.ts +26 -0
  197. package/dist/lib/interactive-wizard.d.ts.map +1 -0
  198. package/dist/lib/interactive-wizard.js +550 -0
  199. package/dist/lib/interactive-wizard.js.map +1 -0
  200. package/dist/lib/oss-surface.d.ts +21 -0
  201. package/dist/lib/oss-surface.d.ts.map +1 -0
  202. package/dist/lib/oss-surface.js +29 -0
  203. package/dist/lib/oss-surface.js.map +1 -0
  204. package/dist/lib/output.d.ts +74 -0
  205. package/dist/lib/output.d.ts.map +1 -0
  206. package/dist/lib/output.js +156 -0
  207. package/dist/lib/output.js.map +1 -0
  208. package/dist/lib/picker.d.ts +75 -0
  209. package/dist/lib/picker.d.ts.map +1 -0
  210. package/dist/lib/picker.js +115 -0
  211. package/dist/lib/picker.js.map +1 -0
  212. package/dist/lib/repl.d.ts +19 -0
  213. package/dist/lib/repl.d.ts.map +1 -0
  214. package/dist/lib/repl.js +158 -0
  215. package/dist/lib/repl.js.map +1 -0
  216. package/dist/lib/spinner.d.ts +41 -0
  217. package/dist/lib/spinner.d.ts.map +1 -0
  218. package/dist/lib/spinner.js +126 -0
  219. package/dist/lib/spinner.js.map +1 -0
  220. package/dist/lib/telemetry.d.ts +96 -0
  221. package/dist/lib/telemetry.d.ts.map +1 -0
  222. package/dist/lib/telemetry.js +367 -0
  223. package/dist/lib/telemetry.js.map +1 -0
  224. package/package.json +68 -0
@@ -0,0 +1,1186 @@
1
+ /**
2
+ * Copyright 2024 Pax8, Inc.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ import { Command } from "commander";
17
+ import { resolve, join } from "node:path";
18
+ import { randomUUID } from "node:crypto";
19
+ import { existsSync, readdirSync, statSync, unlinkSync, readFileSync, writeFileSync, } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import chalk from "chalk";
22
+ import { createSpinner, isQuietMode } from "../lib/spinner.js";
23
+ import Table from "cli-table3";
24
+ import { output, resolveFormat } from "../lib/output.js";
25
+ import { TokenManager, DataverseClient, SolutionOperations, UrlTemplater, WaveService, DeploymentService, DEMO_SOLUTIONS, demoDeploymentStore, getEffectiveConnectionMappings, getEffectiveEnvironmentVariables, loadConfig, resolveTenantUrls, environmentSetupService, detectSolutionMode, getDemoTenantMetadata, } from "@pax8-cta/core";
26
+ import { isDemo, withResolvedDestinations } from "../lib/command-wrapper.js";
27
+ import { getClientSecretWithFallback } from "../lib/credentials.js";
28
+ import { CliError, handleCommandError } from "../lib/errors.js";
29
+ import { isInteractivePrompt, pickFromList, printRunningCommand } from "../lib/picker.js";
30
+ import { showDemoBanner } from "../lib/demo-banner.js";
31
+ const DESTINATION_COLUMNS = [
32
+ { key: "name", header: "Destination" },
33
+ { key: "tenantIdShort", header: "Tenant ID" },
34
+ { key: "hostname", header: "Port" },
35
+ { key: "tags", header: "Tags" },
36
+ ];
37
+ const DEPLOY_RESULT_COLUMNS = [
38
+ { key: "tenant", header: "Destination" },
39
+ {
40
+ key: "status",
41
+ header: "Status",
42
+ format: (v) => (v === "success" ? chalk.green("Success") : chalk.red("Failed")),
43
+ },
44
+ { key: "message", header: "Message" },
45
+ ];
46
+ function buildDestinationRows(destinations) {
47
+ return destinations.map((tenant) => ({
48
+ name: tenant.name,
49
+ tenantId: tenant.tenantId,
50
+ tenantIdShort: tenant.tenantId.slice(0, 8) + "...",
51
+ environmentUrl: tenant.environmentUrl,
52
+ hostname: new URL(tenant.environmentUrl).hostname,
53
+ tags: tenant.tags?.join(", ") || "-",
54
+ }));
55
+ }
56
+ /**
57
+ * Walk through visible "what real mode does" stages in demo mode so the
58
+ * deploy doesn't feel instantaneous. ~3 seconds total — short enough not to
59
+ * stall a live demo, long enough to read.
60
+ */
61
+ async function simulateDemoDeployProgress(tenantNames) {
62
+ if (isQuietMode() || !process.stdout.isTTY)
63
+ return;
64
+ const stages = [
65
+ { label: "Authenticating to Microsoft Graph (GDAP delegation)", ms: 600 },
66
+ {
67
+ label: `Resolving ${tenantNames.length} target environment(s) via Power Platform admin`,
68
+ ms: 500,
69
+ },
70
+ { label: "Importing solution to Dataverse Web API", ms: 900 },
71
+ { label: "Activating workflows and publishing customizations", ms: 700 },
72
+ ];
73
+ for (const stage of stages) {
74
+ const sp = createSpinner(stage.label).start();
75
+ await new Promise((r) => setTimeout(r, stage.ms));
76
+ sp.succeed(stage.label);
77
+ }
78
+ console.log();
79
+ }
80
+ /**
81
+ * Persist a demo-mode deploy to the in-process `demoDeploymentStore` so
82
+ * `deployments list` / `deployments show <id>` immediately surface the same
83
+ * tracking ID the user just saw printed.
84
+ *
85
+ * The shape mirrors `generateMockDeployment` (a `DeploymentJob`): one
86
+ * tenant-result entry per destination, all marked `completed`, with start/end
87
+ * timestamps clustered around `now` so duration calculations look reasonable.
88
+ */
89
+ function recordDemoDeployment(opts) {
90
+ const now = Date.now();
91
+ const startedAtIso = new Date(now).toISOString();
92
+ // Stagger per-tenant completion so the table shows a tiny ramp instead of
93
+ // every tenant finishing at the exact same instant.
94
+ const tenantResults = opts.destinations.map((tenant, index) => ({
95
+ tenantId: tenant.tenantId,
96
+ tenantName: tenant.name,
97
+ status: "completed",
98
+ startedAt: new Date(now + index * 1000).toISOString(),
99
+ completedAt: new Date(now + (index + 1) * 5000).toISOString(),
100
+ solutionImportJobId: `import-${tenant.tenantId.slice(0, 8)}-demo`,
101
+ attemptNumber: 1,
102
+ }));
103
+ const totalTenants = tenantResults.length;
104
+ const durationMs = totalTenants * 5000;
105
+ const completedAtIso = new Date(now + durationMs).toISOString();
106
+ // Solution name: derive from the input. For zip paths, strip the directory
107
+ // and `.zip` suffix so the listing shows something readable.
108
+ const solutionName = opts.isFilePath
109
+ ? opts.solutionInput
110
+ .split(/[\\/]/)
111
+ .pop()
112
+ ?.replace(/\.zip$/i, "") || opts.solutionInput
113
+ : opts.solutionInput;
114
+ demoDeploymentStore.record({
115
+ id: opts.deploymentId,
116
+ solutionPath: opts.isFilePath ? opts.solutionInput : `./solutions/${opts.solutionInput}.zip`,
117
+ solutionName,
118
+ solutionVersion: "1.0.0.2",
119
+ status: "completed",
120
+ createdAt: startedAtIso,
121
+ updatedAt: completedAtIso,
122
+ startedAt: startedAtIso,
123
+ completedAt: completedAtIso,
124
+ tenantResults,
125
+ totalTenants,
126
+ completedTenants: totalTenants,
127
+ failedTenants: 0,
128
+ triggeredBy: "cli",
129
+ durationMs,
130
+ canRollback: true,
131
+ });
132
+ }
133
+ export const deployCommand = new Command("deploy")
134
+ .description("Export a solution from source and import it to target tenants")
135
+ .argument("[solution]", "Solution name (e.g., TestDeploy) or path to zip file")
136
+ .option("-s, --solution <name|path>", "Solution name or path to zip (alternative to argument)")
137
+ .option("--agentPackage <path>", "Alias for --solution")
138
+ .option("-c, --config <path>", "Path to config file", "./config/tenants.yaml")
139
+ .option("-t, --tag <tags...>", "Deploy only to tenants with these tags")
140
+ .option("--tenant <tenants...>", "Deploy only to specific tenant names or IDs")
141
+ .option("--tenants <tenants...>", "Alias for --tenant")
142
+ .option("--all", "Deploy to all configured tenants (default)")
143
+ .option("--dry-run", "Preview what would happen without deploying")
144
+ .option("--skip-validation", "Skip auth and environment checks during dry run")
145
+ .option("--json", "Output dry-run plan as JSON")
146
+ .option("--managed", "Export as managed solution (default)")
147
+ .option("--unmanaged", "Export as unmanaged solution")
148
+ .option("--keep-package", "Keep exported zip after deployment")
149
+ .option("--package-dir <path>", "Directory for exported zip (default: temp)")
150
+ .option("--no-auto-setup", "Skip automatic application user setup")
151
+ .option("--direct", "Deploy sequentially (default mode)")
152
+ .option("--skip-url-replace", "Skip automatic tenant URL replacement in solution")
153
+ .addHelpText("after", `
154
+ Examples:
155
+ deploy TestDeploy --all Deploy to all tenants
156
+ deploy TestDeploy --tag production Deploy to production tenants only
157
+ deploy TestDeploy --all --dry-run Preview without deploying
158
+ deploy ./TestDeploy.zip --all Deploy a pre-exported zip file
159
+ deploy TestDeploy --all --skip-url-replace Skip URL replacement
160
+ `)
161
+ .action(async (solutionArg, options, cmd) => {
162
+ // Merge global flags (--json, --quiet registered on root) into local options.
163
+ // Without this, Commander consumes --json at the root level and deploy's
164
+ // options.json is undefined, breaking `deploy --dry-run --json`.
165
+ Object.assign(options, cmd.optsWithGlobals());
166
+ const mergedTenantFilters = [...(options.tenant ?? []), ...(options.tenants ?? [])];
167
+ if (mergedTenantFilters.length > 0) {
168
+ options.tenant = Array.from(new Set(mergedTenantFilters));
169
+ }
170
+ if (solutionArg && !options.solution)
171
+ options.solution = solutionArg;
172
+ // No solution provided? In an interactive terminal, offer a picker drawn
173
+ // from `./agent packages/*.zip` (and DEMO_SOLUTIONS in demo mode).
174
+ // Scripts/pipelines (--json, --quiet, non-TTY) skip the picker and get
175
+ // the existing usage error so they fail fast instead of hanging.
176
+ if (!options.solution && isInteractivePrompt({ json: options.json, quiet: options.quiet })) {
177
+ const picked = await pickSolutionInteractively();
178
+ if (picked) {
179
+ options.solution = picked;
180
+ }
181
+ }
182
+ if (!options.solution) {
183
+ console.error(chalk.red("Error: solution name or path required."));
184
+ console.error(chalk.gray(" Example: deploy TestDeploy --all"));
185
+ process.exit(2);
186
+ }
187
+ // Resolve the structured output format once — drives --quiet/--json gating
188
+ // and TTY-default JSON for the destinations preview and the post-deploy
189
+ // summary blocks (issue #357). Dry-run keeps its own JSON branch (the
190
+ // existing envelope shape is preserved by runDryRunPreview).
191
+ const fmt = resolveFormat({
192
+ json: options.json,
193
+ quiet: options.quiet,
194
+ });
195
+ // No target selection? In an interactive terminal, offer a picker
196
+ // (tags from the fleet plus an "all" sentinel). Same TTY guard as above.
197
+ if (!options.all &&
198
+ (!options.tag || options.tag.length === 0) &&
199
+ (!options.tenant || options.tenant.length === 0) &&
200
+ isInteractivePrompt({ json: options.json, quiet: options.quiet })) {
201
+ const picked = await pickTargetInteractively(options.config);
202
+ if (picked === "all") {
203
+ options.all = true;
204
+ }
205
+ else if (picked) {
206
+ options.tag = [picked];
207
+ }
208
+ if (options.solution && (options.all || options.tag?.length)) {
209
+ const targetFlag = options.all ? "--all" : `--tag ${options.tag[0]}`;
210
+ printRunningCommand(["deploy", options.solution, ...targetFlag.split(" ")]);
211
+ }
212
+ }
213
+ // Default to --all if no tag filter
214
+ if (!options.all && (!options.tag || options.tag.length === 0)) {
215
+ options.all = true;
216
+ }
217
+ const spinner = createSpinner("Loading configuration...").start();
218
+ // Declare these outside try block so they're accessible in catch
219
+ let agentPackagePath;
220
+ let tempPackagePath = null;
221
+ try {
222
+ // Validate options
223
+ if (!options.all && (!options.tag || options.tag.length === 0)) {
224
+ spinner.fail(chalk.red("Must specify --all or --tag to select destinations"));
225
+ process.exit(1);
226
+ }
227
+ // Determine if this is a solution name or file path
228
+ const solutionArg = options.agentPackage || options.solution;
229
+ // Treat as file path if it ends with .zip (regardless of existence - we'll validate later)
230
+ const isFilePath = solutionArg.endsWith(".zip");
231
+ const resolvedContext = await withResolvedDestinations(options, async (resolvedDestinations) => {
232
+ const destinations = filterDestinationsByTenantSelections(resolvedDestinations, options.tenant);
233
+ if (destinations.length === 0) {
234
+ spinner.fail(chalk.red("No destinations matched the selection criteria"));
235
+ process.exit(1);
236
+ }
237
+ spinner.succeed("Demo fleet manifest loaded");
238
+ // Validate the solution argument against the demo catalog before we
239
+ // pretend to export/ship. Without this, demo mode silently accepts
240
+ // typos like "CusteomrServiceAgent" and prints a fake success — that
241
+ // teaches users the CLI accepts garbage and hides typos during
242
+ // demos. Real-mode deploy already errors when an unknown solution
243
+ // name fails to export, so we only need to backfill the demo path.
244
+ // ZIP path inputs ("./foo.zip") still pretend-export without file
245
+ // existence checks (the existing demo contract for paths).
246
+ if (!isFilePath) {
247
+ const knownDemoNames = DEMO_SOLUTIONS.map((sol) => sol.uniqueName);
248
+ // Case-sensitive match — the CLI is case-sensitive about solution
249
+ // names elsewhere (export/import use the uniqueName verbatim).
250
+ if (!knownDemoNames.includes(solutionArg)) {
251
+ const preview = knownDemoNames.slice(0, 5);
252
+ const lines = [
253
+ `Solution '${solutionArg}' not found in the demo catalog.`,
254
+ "Available demo solutions:",
255
+ ...preview.map((name) => ` - ${name}`),
256
+ "Run 'solutions list' to see all available demo solutions.",
257
+ ];
258
+ throw new CliError(lines.join("\n"));
259
+ }
260
+ }
261
+ // DEMO MODE banner is informational chrome — keep it on stderr but
262
+ // suppress under --quiet/--json (callers piping JSON shouldn't see
263
+ // unstructured noise on either stream during automated runs).
264
+ if (fmt === "table") {
265
+ showDemoBanner();
266
+ }
267
+ if (options.dryRun) {
268
+ await runDryRunPreview({
269
+ solutionInput: solutionArg,
270
+ isFilePath,
271
+ options,
272
+ destinations,
273
+ demoMode: true,
274
+ });
275
+ return null;
276
+ }
277
+ const destinationRows = buildDestinationRows(destinations);
278
+ const demoDeploymentId = `dep-demo-${Date.now().toString(36)}`;
279
+ const packageLabel = isFilePath ? solutionArg : `${solutionArg} (exported)`;
280
+ // Record the demo deploy in the in-process store so a follow-up
281
+ // `deployments list` / `deployments show <id>` finds the tracking ID
282
+ // we just printed. Without this, the natural
283
+ // "I just deployed → show me what landed" demo beat broke because
284
+ // the listing came purely from canned `generateMockDeploymentHistory`.
285
+ recordDemoDeployment({
286
+ deploymentId: demoDeploymentId,
287
+ solutionInput: solutionArg,
288
+ isFilePath,
289
+ destinations,
290
+ managed: !options.unmanaged,
291
+ });
292
+ if (fmt === "json") {
293
+ // Demo success envelope — distinct from the dry-run plan shape.
294
+ console.log(JSON.stringify({
295
+ demo: true,
296
+ deploymentId: demoDeploymentId,
297
+ package: packageLabel,
298
+ solution: solutionArg,
299
+ managed: !options.unmanaged,
300
+ destinations: destinationRows.map((row) => ({
301
+ name: row.name,
302
+ tenantId: row.tenantId,
303
+ environmentUrl: row.environmentUrl,
304
+ tags: destinations.find((t) => t.tenantId === row.tenantId)?.tags ?? [],
305
+ })),
306
+ totalDestinations: destinations.length,
307
+ }, null, 2));
308
+ return null;
309
+ }
310
+ if (fmt === "quiet") {
311
+ // No-op — caller cares about exit code only.
312
+ return null;
313
+ }
314
+ // Human-readable (table) path.
315
+ if (!isFilePath) {
316
+ console.log(chalk.bold("📤 Export Simulation:"));
317
+ console.log(` Solution: ${chalk.green(solutionArg)}`);
318
+ console.log(` Version: 1.0.0.2 (demo)`);
319
+ console.log(` Type: ${options.unmanaged ? "Unmanaged" : "Managed"}`);
320
+ console.log();
321
+ }
322
+ console.log(chalk.bold(`🎯 Deployment Targets (${destinations.length}):`));
323
+ console.log();
324
+ output(destinationRows, { format: "table", columns: DESTINATION_COLUMNS });
325
+ console.log();
326
+ // Simulate the deploy work so the demo doesn't feel instantaneous.
327
+ // Real deploys hit Microsoft Graph + Dataverse Web API per tenant —
328
+ // this mirrors that activity at a watchable pace.
329
+ await simulateDemoDeployProgress(destinations.map((t) => t.name));
330
+ console.log(chalk.green("✓ Deployment dispatched successfully (demo)"));
331
+ console.log();
332
+ console.log(chalk.bold("📋 Deployment Details:"));
333
+ console.log(` Deployment ID: ${chalk.cyan(demoDeploymentId)}`);
334
+ console.log(` Solution: ${packageLabel}`);
335
+ console.log(` Target tenants: ${destinations.length}`);
336
+ console.log();
337
+ console.log(chalk.gray(`Use 'pax8-cta deployments show ${demoDeploymentId}' to track progress`));
338
+ return null;
339
+ }, async (context) => {
340
+ spinner.succeed("Manifest loaded");
341
+ return context;
342
+ });
343
+ if (!resolvedContext) {
344
+ return;
345
+ }
346
+ const { config } = resolvedContext;
347
+ const destinations = filterDestinationsByTenantSelections(resolvedContext.destinations, options.tenant);
348
+ if (destinations.length === 0) {
349
+ spinner.fail(chalk.red("No destinations matched the selection criteria"));
350
+ process.exit(1);
351
+ }
352
+ if (options.dryRun) {
353
+ await runDryRunPreview({
354
+ solutionInput: solutionArg,
355
+ isFilePath,
356
+ options,
357
+ destinations,
358
+ config,
359
+ });
360
+ return;
361
+ }
362
+ // If solution name provided, export it first
363
+ if (!isFilePath) {
364
+ // Validate source environment is configured
365
+ if (!config.source || !config.source.environmentUrl) {
366
+ spinner.fail(chalk.red("Source environment not configured"));
367
+ console.error(chalk.red("\nTo deploy from solution name, configure a source environment in your config file:"));
368
+ console.error(chalk.gray(" source:"));
369
+ console.error(chalk.gray(" tenantId: <tenant-id>"));
370
+ console.error(chalk.gray(" environmentUrl: <environment-url>"));
371
+ console.error(chalk.gray("\nOr set environment variables: SOURCE_TENANT_ID, SOURCE_ENVIRONMENT_URL"));
372
+ process.exit(1);
373
+ }
374
+ // Get client secret once for reuse
375
+ const clientSecret = await getClientSecretWithFallback();
376
+ // Auto-detect solution mode if not explicitly specified
377
+ let managed = !options.unmanaged;
378
+ if (!options.managed && !options.unmanaged) {
379
+ spinner.start("Detecting solution mode in target environments...");
380
+ const modeCheck = await detectSolutionMode(solutionArg, destinations, config.partner.clientId, clientSecret);
381
+ if (modeCheck.hasConflict) {
382
+ spinner.warn(chalk.yellow("Mixed solution modes detected in targets"));
383
+ if (fmt === "table") {
384
+ console.log();
385
+ console.log(chalk.yellow("⚠ Warning: Solution exists with different modes:"));
386
+ if (modeCheck.managedCount > 0) {
387
+ console.log(chalk.gray(` ${modeCheck.managedCount} target(s) have it as managed`));
388
+ }
389
+ if (modeCheck.unmanagedCount > 0) {
390
+ console.log(chalk.gray(` ${modeCheck.unmanagedCount} target(s) have it as unmanaged`));
391
+ }
392
+ if (modeCheck.notInstalledCount > 0) {
393
+ console.log(chalk.gray(` ${modeCheck.notInstalledCount} target(s) don't have it installed`));
394
+ }
395
+ console.log();
396
+ console.log(chalk.gray("Use --managed or --unmanaged to specify which mode to use."));
397
+ console.log(chalk.gray("Targets with mismatched mode will fail to import."));
398
+ console.log();
399
+ }
400
+ // Default to majority mode
401
+ managed = modeCheck.managedCount >= modeCheck.unmanagedCount;
402
+ if (fmt === "table") {
403
+ console.log(chalk.cyan(`Proceeding with ${managed ? "managed" : "unmanaged"} mode (majority)`));
404
+ console.log();
405
+ }
406
+ }
407
+ else if (modeCheck.unmanagedCount > 0) {
408
+ // All existing installations are unmanaged
409
+ managed = false;
410
+ spinner.succeed(`Auto-detected: exporting as unmanaged (matches ${modeCheck.unmanagedCount} target(s))`);
411
+ }
412
+ else if (modeCheck.managedCount > 0) {
413
+ // All existing installations are managed
414
+ managed = true;
415
+ spinner.succeed(`Auto-detected: exporting as managed (matches ${modeCheck.managedCount} target(s))`);
416
+ }
417
+ else {
418
+ // Not installed anywhere - use default (managed)
419
+ spinner.succeed("Solution not installed in targets - using managed mode (default)");
420
+ }
421
+ }
422
+ spinner.start(`Exporting solution '${solutionArg}' from source...`);
423
+ try {
424
+ // Authenticate and create client for source
425
+ const tokenManager = new TokenManager({
426
+ tenantId: config.partner.tenantId,
427
+ clientId: config.partner.clientId,
428
+ clientSecret,
429
+ });
430
+ const dataverseClient = new DataverseClient({
431
+ environmentUrl: config.source.environmentUrl,
432
+ tokenManager,
433
+ });
434
+ const solutionOps = new SolutionOperations(dataverseClient);
435
+ // Export with detected/specified mode
436
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
437
+ const suffix = managed ? "managed" : "unmanaged";
438
+ // Determine output directory
439
+ const outputDir = options.packageDir ? resolve(options.packageDir) : tmpdir();
440
+ const outputPath = join(outputDir, `${solutionArg}_${timestamp}_${suffix}.zip`);
441
+ // Export solution
442
+ const metadata = await solutionOps.exportSolution(solutionArg, {
443
+ managed,
444
+ outputPath,
445
+ });
446
+ spinner.succeed(`Exported ${chalk.green(metadata.friendlyName)} v${metadata.version} (${suffix})`);
447
+ agentPackagePath = outputPath;
448
+ if (!options.keepPackage) {
449
+ tempPackagePath = outputPath;
450
+ }
451
+ if (fmt === "table") {
452
+ console.log();
453
+ }
454
+ }
455
+ catch (error) {
456
+ handleCommandError(error, spinner, "Export failed");
457
+ }
458
+ }
459
+ else {
460
+ agentPackagePath = resolve(solutionArg);
461
+ // Validate file exists (skip in dry-run mode where we just want to preview)
462
+ if (!options.dryRun && !existsSync(agentPackagePath)) {
463
+ spinner.fail(chalk.red(`Package not found: ${agentPackagePath}`));
464
+ process.exit(1);
465
+ }
466
+ }
467
+ // Display destinations through the structured output() helper so
468
+ // --quiet/--json/TTY-default JSON behave consistently (issue #357).
469
+ const destinationRows = buildDestinationRows(destinations);
470
+ if (fmt === "table") {
471
+ console.log();
472
+ console.log(chalk.bold(`Deployment Targets (${destinations.length}):`));
473
+ output(destinationRows, { format: "table", columns: DESTINATION_COLUMNS });
474
+ console.log();
475
+ }
476
+ // Verify client secret is available
477
+ await getClientSecretWithFallback();
478
+ // Auto-setup app users if needed (unless --no-auto-setup)
479
+ if (options.autoSetup !== false) {
480
+ if (fmt === "table") {
481
+ console.log(chalk.bold("Checking application users..."));
482
+ }
483
+ let setupCount = 0;
484
+ let warningCount = 0;
485
+ const setupClientSecret = await getClientSecretWithFallback();
486
+ for (const tenant of destinations) {
487
+ const prepareSpinner = createSpinner(`Checking ${tenant.name}...`).start();
488
+ const setupTokenManager = new TokenManager({
489
+ tenantId: tenant.tenantId,
490
+ clientId: config.partner.clientId,
491
+ clientSecret: setupClientSecret,
492
+ });
493
+ const setupClient = new DataverseClient({
494
+ environmentUrl: tenant.environmentUrl,
495
+ tokenManager: setupTokenManager,
496
+ });
497
+ const prepared = await environmentSetupService.prepareEnvironment(setupClient, config.partner.clientId, tenant.environmentUrl);
498
+ if (prepared.success) {
499
+ prepareSpinner.succeed(chalk.green(`${tenant.name}: ${prepared.message}`));
500
+ if (prepared.message.includes("Created") || prepared.message.includes("Assigned")) {
501
+ setupCount++;
502
+ }
503
+ }
504
+ else {
505
+ prepareSpinner.warn(chalk.yellow(`${tenant.name}: ${prepared.message}`));
506
+ warningCount++;
507
+ }
508
+ }
509
+ if (fmt === "table") {
510
+ console.log();
511
+ if (setupCount > 0) {
512
+ console.log(chalk.green(`✓ ${setupCount} environment(s) setup completed`));
513
+ }
514
+ if (warningCount > 0) {
515
+ console.log(chalk.yellow(`⚠ ${warningCount} environment(s) skipped (see warnings above)`));
516
+ }
517
+ console.log();
518
+ }
519
+ }
520
+ // Deploy directly to each tenant sequentially
521
+ if (fmt === "table") {
522
+ console.log(chalk.bold("Deploying to destinations...\n"));
523
+ }
524
+ const clientSecret = await getClientSecretWithFallback();
525
+ let successCount = 0;
526
+ let failCount = 0;
527
+ // Track per-tenant outcomes so we can render a structured summary at the
528
+ // end (table for humans, rows in the JSON envelope for pipelines).
529
+ const deployResults = [];
530
+ // Scan solution for tenant-specific URLs (once, before the loop)
531
+ let detectedUrls = [];
532
+ if (!options.skipUrlReplace) {
533
+ try {
534
+ const JSZip = (await import("jszip")).default;
535
+ const zipBuffer = readFileSync(agentPackagePath);
536
+ const zip = await JSZip.loadAsync(zipBuffer);
537
+ const templater = new UrlTemplater();
538
+ detectedUrls = await templater.scanSolution(zip);
539
+ if (detectedUrls.length > 0 && fmt === "table") {
540
+ const sourceTenant = templater.inferSourceTenant(detectedUrls);
541
+ console.log(chalk.gray(`Found ${detectedUrls.length} tenant-specific URL(s) from source tenant "${sourceTenant}" — will replace per target`));
542
+ console.log();
543
+ }
544
+ }
545
+ catch {
546
+ // JSZip may not be available or ZIP scan failed — skip URL replacement
547
+ }
548
+ }
549
+ for (const tenant of destinations) {
550
+ const tenantSpinner = createSpinner(`Deploying to ${tenant.name}...`).start();
551
+ try {
552
+ const tokenManager = new TokenManager({
553
+ tenantId: tenant.tenantId,
554
+ clientId: config.partner.clientId,
555
+ clientSecret,
556
+ });
557
+ const dataverseClient = new DataverseClient({
558
+ environmentUrl: tenant.environmentUrl,
559
+ tokenManager,
560
+ });
561
+ const solutionOps = new SolutionOperations(dataverseClient);
562
+ // Apply URL replacements if tenant-specific URLs were detected
563
+ let importPath = agentPackagePath;
564
+ if (detectedUrls.length > 0) {
565
+ try {
566
+ const modifiedPath = await applyUrlReplacements(agentPackagePath, detectedUrls, tenant);
567
+ if (modifiedPath) {
568
+ importPath = modifiedPath;
569
+ }
570
+ }
571
+ catch {
572
+ // URL replacement failed — import original solution
573
+ }
574
+ }
575
+ // Start async import
576
+ const importJobId = await solutionOps.importSolutionAsync(importPath, {
577
+ overwriteUnmanagedCustomizations: true,
578
+ publishWorkflows: true,
579
+ });
580
+ // Clean up temp modified ZIP after import starts
581
+ if (importPath !== agentPackagePath && existsSync(importPath)) {
582
+ try {
583
+ unlinkSync(importPath);
584
+ }
585
+ catch {
586
+ /* ignore */
587
+ }
588
+ }
589
+ // Wait for completion with progress
590
+ const result = await solutionOps.waitForImport(importJobId, {
591
+ pollIntervalMs: 3000,
592
+ timeoutMs: 300000,
593
+ onProgress: (progress) => {
594
+ tenantSpinner.text = `Deploying to ${tenant.name}... ${Math.round(progress)}%`;
595
+ },
596
+ });
597
+ if (result.success) {
598
+ tenantSpinner.succeed(chalk.green(`${tenant.name}: Deployed successfully`));
599
+ successCount++;
600
+ deployResults.push({
601
+ tenant: tenant.name,
602
+ tenantId: tenant.tenantId,
603
+ status: "success",
604
+ message: "Deployed successfully",
605
+ });
606
+ }
607
+ else {
608
+ const message = result.error || "Deployment failed";
609
+ tenantSpinner.fail(chalk.red(`${tenant.name}: ${message}`));
610
+ failCount++;
611
+ deployResults.push({
612
+ tenant: tenant.name,
613
+ tenantId: tenant.tenantId,
614
+ status: "failed",
615
+ message,
616
+ });
617
+ }
618
+ }
619
+ catch (error) {
620
+ const errorMsg = error instanceof Error ? error.message : String(error);
621
+ tenantSpinner.fail(chalk.red(`${tenant.name}: ${errorMsg}`));
622
+ failCount++;
623
+ deployResults.push({
624
+ tenant: tenant.name,
625
+ tenantId: tenant.tenantId,
626
+ status: "failed",
627
+ message: errorMsg,
628
+ });
629
+ }
630
+ }
631
+ // Render the post-deploy summary through the same output() pipeline as
632
+ // tenants-list / deployments-list. JSON callers get a structured envelope
633
+ // (results[] + counts); --quiet stays silent; the human path keeps the
634
+ // bold "Deployment Summary" block.
635
+ if (fmt === "json") {
636
+ console.log(JSON.stringify({
637
+ demo: false,
638
+ solution: solutionArg,
639
+ total: destinations.length,
640
+ success: successCount,
641
+ failed: failCount,
642
+ results: deployResults,
643
+ }, null, 2));
644
+ }
645
+ else if (fmt === "table") {
646
+ console.log();
647
+ console.log(chalk.bold("Deployment Summary:"));
648
+ output(deployResults, { format: "table", columns: DEPLOY_RESULT_COLUMNS });
649
+ console.log(` Total: ${destinations.length}`);
650
+ console.log(` ${chalk.green("Success:")} ${successCount}`);
651
+ if (failCount > 0) {
652
+ console.log(` ${chalk.red("Failed:")} ${failCount}`);
653
+ }
654
+ }
655
+ // fmt === "quiet" (and any future formats) intentionally produce no
656
+ // success-path stdout.
657
+ if (failCount > 0) {
658
+ process.exit(1);
659
+ }
660
+ // Clean up temp package if needed
661
+ if (tempPackagePath && existsSync(tempPackagePath)) {
662
+ try {
663
+ unlinkSync(tempPackagePath);
664
+ }
665
+ catch {
666
+ // Ignore cleanup errors
667
+ }
668
+ }
669
+ }
670
+ catch (error) {
671
+ // Clean up temp package on error
672
+ if (tempPackagePath && existsSync(tempPackagePath)) {
673
+ try {
674
+ unlinkSync(tempPackagePath);
675
+ }
676
+ catch {
677
+ // Ignore cleanup errors
678
+ }
679
+ }
680
+ handleCommandError(error, spinner, "Deployment failed");
681
+ }
682
+ });
683
+ /**
684
+ * Demo-mode dry-run validation derived from the tenant's demo metadata.
685
+ *
686
+ * Uses the same gdapStatus / connectionStatus → severity mapping as the
687
+ * `solutions drift --risk` analyzer (`risk-analyzer.ts:283-410`) so the two
688
+ * views never disagree about a tenant. Tenants without demo metadata fall
689
+ * back to the previous "skipped" behaviour so non-demo configs are
690
+ * unaffected.
691
+ */
692
+ function deriveDemoValidation(tenant) {
693
+ const meta = getDemoTenantMetadata(tenant.tenantId);
694
+ if (!meta) {
695
+ return { status: "skipped", errors: [], warnings: ["No demo metadata available"] };
696
+ }
697
+ const errors = [];
698
+ const warnings = [];
699
+ switch (meta.gdapStatus) {
700
+ case "missing_role":
701
+ errors.push("Missing Power Platform Admin role on GDAP relationship");
702
+ break;
703
+ case "expired":
704
+ errors.push("GDAP relationship expired");
705
+ break;
706
+ case "propagating":
707
+ warnings.push("GDAP recently added — permissions may not have propagated yet");
708
+ break;
709
+ case "expiring_soon":
710
+ warnings.push("GDAP relationship expires within 7 days");
711
+ break;
712
+ }
713
+ switch (meta.connectionStatus) {
714
+ case "expired":
715
+ errors.push("Expired connection references");
716
+ break;
717
+ case "missing":
718
+ errors.push("Connection references missing");
719
+ break;
720
+ case "expiring_certificate":
721
+ warnings.push("Connection certificate expires soon");
722
+ break;
723
+ }
724
+ if (meta.recentFailures >= 3) {
725
+ warnings.push(`${meta.recentFailures} recent deploy failures on this tenant`);
726
+ }
727
+ if (meta.riskProfile === "production-critical" && errors.length === 0) {
728
+ warnings.push("Production-critical tenant — approval recommended");
729
+ }
730
+ return {
731
+ status: errors.length > 0 ? "fail" : "pass",
732
+ errors,
733
+ warnings,
734
+ };
735
+ }
736
+ /**
737
+ * Build the candidate list for the solution picker:
738
+ * - Every `*.zip` under `./agent packages/` (most-recent first).
739
+ * - In demo mode, also surface the synthetic DEMO_SOLUTIONS by uniqueName
740
+ * so users exploring without real exports still have something to pick.
741
+ */
742
+ function gatherSolutionCandidates() {
743
+ const items = [];
744
+ const agentPackagesDir = resolve(process.cwd(), "agent packages");
745
+ if (existsSync(agentPackagesDir)) {
746
+ try {
747
+ const zips = readdirSync(agentPackagesDir)
748
+ .filter((f) => f.endsWith(".zip"))
749
+ .map((f) => {
750
+ const path = join(agentPackagesDir, f);
751
+ return { name: f, path, mtime: statSync(path).mtimeMs };
752
+ })
753
+ .sort((a, b) => b.mtime - a.mtime);
754
+ for (const zip of zips) {
755
+ items.push({
756
+ display: zip.name,
757
+ value: zip.path,
758
+ hint: "agent packages/",
759
+ });
760
+ }
761
+ }
762
+ catch {
763
+ // Directory unreadable — silently skip; user can still type a path.
764
+ }
765
+ }
766
+ if (isDemo()) {
767
+ for (const demo of DEMO_SOLUTIONS) {
768
+ // Skip if already represented by a real zip with the same base name.
769
+ if (items.some((it) => it.display.startsWith(demo.uniqueName)))
770
+ continue;
771
+ items.push({
772
+ display: demo.uniqueName,
773
+ value: demo.uniqueName,
774
+ hint: "demo",
775
+ });
776
+ }
777
+ }
778
+ return items;
779
+ }
780
+ async function pickSolutionInteractively() {
781
+ const candidates = gatherSolutionCandidates();
782
+ if (candidates.length === 0)
783
+ return undefined;
784
+ const chosen = await pickFromList(candidates, {
785
+ prompt: "Pick a solution to deploy:",
786
+ label: (it) => it.display,
787
+ hint: (it) => it.hint,
788
+ });
789
+ return chosen?.value;
790
+ }
791
+ /**
792
+ * Build the target picker — distinct tags drawn from the fleet plus an
793
+ * "all" sentinel for "deploy everywhere". Returns the selected tag string,
794
+ * the literal `"all"`, or undefined if the user skipped.
795
+ */
796
+ async function pickTargetInteractively(configPath) {
797
+ let tenants = [];
798
+ try {
799
+ if (isDemo()) {
800
+ const { getDemoTenants } = await import("./demo.js");
801
+ tenants = getDemoTenants({ all: true });
802
+ }
803
+ else {
804
+ const path = resolve(process.cwd(), configPath ?? "./config/tenants.yaml");
805
+ const config = await loadConfig(path);
806
+ tenants = config.tenants.filter((t) => t.enabled);
807
+ }
808
+ }
809
+ catch {
810
+ // Config unavailable — let the caller fall through to the default --all.
811
+ return undefined;
812
+ }
813
+ const tagSet = new Set();
814
+ for (const t of tenants) {
815
+ for (const tag of t.tags ?? [])
816
+ tagSet.add(tag);
817
+ }
818
+ const items = [
819
+ { display: "all tenants", value: "all", hint: `${tenants.length} enabled` },
820
+ ...Array.from(tagSet)
821
+ .sort()
822
+ .map((tag) => ({
823
+ display: `--tag ${tag}`,
824
+ value: tag,
825
+ hint: `${tenants.filter((t) => t.tags?.includes(tag)).length} tenant(s)`,
826
+ })),
827
+ ];
828
+ if (items.length === 1) {
829
+ // Only "all" is available — no point prompting; the default kicks in.
830
+ return undefined;
831
+ }
832
+ const chosen = await pickFromList(items, {
833
+ prompt: "Pick a deployment target:",
834
+ label: (it) => it.display,
835
+ hint: (it) => it.hint,
836
+ });
837
+ return chosen?.value;
838
+ }
839
+ function filterDestinationsByTenantSelections(destinations, tenantFilters) {
840
+ if (!tenantFilters || tenantFilters.length === 0) {
841
+ return destinations;
842
+ }
843
+ const normalizedFilters = tenantFilters.map((filter) => filter.toLowerCase());
844
+ return destinations.filter((tenant) => {
845
+ const tenantName = tenant.name.toLowerCase();
846
+ const tenantId = tenant.tenantId.toLowerCase();
847
+ const environmentUrl = tenant.environmentUrl.toLowerCase();
848
+ return normalizedFilters.some((filter) => tenantName.includes(filter) || tenantId === filter || environmentUrl.includes(filter));
849
+ });
850
+ }
851
+ async function runDryRunPreview(context) {
852
+ const plan = await buildDryRunPlan(context);
853
+ // Honor --quiet, --json, and TTY-default JSON when piped (issue #357).
854
+ // ids-only/csv aren't meaningful for a dry-run plan; treat anything that
855
+ // resolves to a non-quiet/non-json format as "table" (the human path).
856
+ const fmt = resolveFormat({
857
+ json: context.options.json,
858
+ quiet: context.options.quiet,
859
+ });
860
+ if (fmt === "quiet") {
861
+ // No output; exit code below still reflects validation failures.
862
+ }
863
+ else if (fmt === "json") {
864
+ console.log(JSON.stringify(plan, null, 2));
865
+ }
866
+ else {
867
+ displayDryRunPlan(plan);
868
+ }
869
+ if (plan.summary.validationFailedTenants > 0) {
870
+ process.exit(1);
871
+ }
872
+ }
873
+ async function buildDryRunPlan(context) {
874
+ const templater = new UrlTemplater();
875
+ const detectedTemplatePatterns = await detectTemplatePatternsFromPackage(context.solutionInput, context.isFilePath, context.options.skipUrlReplace === true);
876
+ const validationByTenant = new Map();
877
+ if (context.demoMode) {
878
+ // Derive plausible per-tenant validation from demo metadata so the dry-run
879
+ // table shows real signal (PASS / WARN / FAIL with reasons) instead of a
880
+ // wall of "SKIPPED" cells. Drives the demo story "this tool actually
881
+ // checked each tenant before deploying."
882
+ for (const tenant of context.destinations) {
883
+ validationByTenant.set(tenant.tenantId, deriveDemoValidation(tenant));
884
+ }
885
+ }
886
+ else if (context.options.skipValidation) {
887
+ for (const tenant of context.destinations) {
888
+ validationByTenant.set(tenant.tenantId, {
889
+ status: "skipped",
890
+ errors: [],
891
+ warnings: ["Skipped (--skip-validation)"],
892
+ });
893
+ }
894
+ }
895
+ else if (context.config) {
896
+ let deploymentService = null;
897
+ let bootstrapError = null;
898
+ try {
899
+ const clientSecret = await getClientSecretWithFallback();
900
+ deploymentService = new DeploymentService({
901
+ tenantId: context.config.partner.tenantId,
902
+ clientId: context.config.partner.clientId,
903
+ clientSecret,
904
+ });
905
+ }
906
+ catch (error) {
907
+ bootstrapError = error instanceof Error ? error.message : String(error);
908
+ }
909
+ if (bootstrapError) {
910
+ for (const tenant of context.destinations) {
911
+ validationByTenant.set(tenant.tenantId, {
912
+ status: "fail",
913
+ errors: [bootstrapError],
914
+ warnings: [],
915
+ });
916
+ }
917
+ }
918
+ else if (deploymentService) {
919
+ for (const tenant of context.destinations) {
920
+ const effectiveMappings = getEffectiveConnectionMappings(context.config, tenant);
921
+ const effectiveVariables = getEffectiveEnvironmentVariables(context.config, tenant);
922
+ const validationResult = await deploymentService.validateTenant({
923
+ tenantId: tenant.tenantId,
924
+ tenantName: tenant.name,
925
+ environmentUrl: tenant.environmentUrl,
926
+ connectionMappings: effectiveMappings,
927
+ environmentVariables: effectiveVariables,
928
+ autoSetup: tenant.autoSetup,
929
+ });
930
+ validationByTenant.set(tenant.tenantId, {
931
+ status: validationResult.valid ? "pass" : "fail",
932
+ errors: validationResult.errors,
933
+ warnings: validationResult.warnings,
934
+ });
935
+ }
936
+ }
937
+ }
938
+ const waveService = new WaveService();
939
+ const executionPlan = context.config
940
+ ? waveService.createExecutionPlan(context.config, context.destinations)
941
+ : {
942
+ waves: [
943
+ {
944
+ waveNumber: 1,
945
+ name: "Default",
946
+ tenants: context.destinations,
947
+ continueOnFailure: false,
948
+ },
949
+ ],
950
+ totalTenants: context.destinations.length,
951
+ };
952
+ const waves = executionPlan.waves.map((wave) => {
953
+ const tenants = wave.tenants.map((tenant) => {
954
+ const tenantUrls = resolveTenantUrls(tenant);
955
+ const connectionMappings = context.config
956
+ ? getEffectiveConnectionMappings(context.config, tenant)
957
+ : tenant.connectionMappings || [];
958
+ const environmentVariables = context.config
959
+ ? getEffectiveEnvironmentVariables(context.config, tenant)
960
+ : tenant.environmentVariables || [];
961
+ const connectionPreviews = connectionMappings.map((mapping) => ({
962
+ sourceLogicalName: mapping.sourceLogicalName,
963
+ targetConnectionId: mapping.targetConnectionId,
964
+ resolvedTargetConnectionId: templater.resolveTemplate(mapping.targetConnectionId, tenantUrls),
965
+ }));
966
+ const variablePreviews = environmentVariables.map((variable) => ({
967
+ schemaName: variable.schemaName,
968
+ value: variable.value,
969
+ resolvedValue: typeof variable.value === "string"
970
+ ? templater.resolveTemplate(variable.value, tenantUrls)
971
+ : variable.value,
972
+ }));
973
+ const templatePatterns = collectTemplatePatterns(detectedTemplatePatterns, connectionPreviews, variablePreviews);
974
+ const urlResolutions = templatePatterns.length > 0
975
+ ? templatePatterns.map((template) => ({
976
+ template,
977
+ resolved: templater.resolveTemplate(template, tenantUrls),
978
+ }))
979
+ : [
980
+ { template: "{tenant}", resolved: tenantUrls.tenant },
981
+ { template: "{tenant}.sharepoint.com", resolved: tenantUrls.sharepoint },
982
+ { template: "{tenant}.onmicrosoft.com", resolved: tenantUrls.onmicrosoft },
983
+ ];
984
+ return {
985
+ tenantName: tenant.name,
986
+ tenantId: tenant.tenantId,
987
+ environmentUrl: tenant.environmentUrl,
988
+ waveNumber: wave.waveNumber,
989
+ waveName: wave.name,
990
+ connectionMappings: connectionPreviews,
991
+ environmentVariables: variablePreviews,
992
+ urlResolutions,
993
+ validation: validationByTenant.get(tenant.tenantId) || {
994
+ status: "skipped",
995
+ errors: [],
996
+ warnings: ["Validation unavailable"],
997
+ },
998
+ };
999
+ });
1000
+ return {
1001
+ waveNumber: wave.waveNumber,
1002
+ name: wave.name,
1003
+ maxParallel: wave.maxParallel ?? 1,
1004
+ waitAfterCompletionMs: wave.waitAfterCompletion,
1005
+ continueOnFailure: wave.continueOnFailure,
1006
+ tenants,
1007
+ };
1008
+ });
1009
+ return {
1010
+ dryRun: true,
1011
+ generatedAt: new Date().toISOString(),
1012
+ solution: context.solutionInput,
1013
+ summary: {
1014
+ totalTenants: context.destinations.length,
1015
+ totalWaves: waves.length,
1016
+ validationEnabled: !(context.options.skipValidation || context.demoMode),
1017
+ validationFailedTenants: waves.reduce((count, wave) => count + wave.tenants.filter((tenant) => tenant.validation.status === "fail").length, 0),
1018
+ },
1019
+ waves,
1020
+ };
1021
+ }
1022
+ async function detectTemplatePatternsFromPackage(solutionInput, isFilePath, skipUrlReplace) {
1023
+ if (!isFilePath || skipUrlReplace) {
1024
+ return [];
1025
+ }
1026
+ const packagePath = resolve(solutionInput);
1027
+ if (!existsSync(packagePath)) {
1028
+ return [];
1029
+ }
1030
+ try {
1031
+ const JSZip = (await import("jszip")).default;
1032
+ const zipBuffer = readFileSync(packagePath);
1033
+ const zip = await JSZip.loadAsync(zipBuffer);
1034
+ const templater = new UrlTemplater();
1035
+ const detectedUrls = await templater.scanSolution(zip);
1036
+ return Array.from(new Set(detectedUrls.map((url) => url.templatePattern)));
1037
+ }
1038
+ catch {
1039
+ return [];
1040
+ }
1041
+ }
1042
+ function collectTemplatePatterns(detectedPatterns, connectionPreviews, variablePreviews) {
1043
+ const patterns = new Set(detectedPatterns);
1044
+ for (const mapping of connectionPreviews) {
1045
+ if (mapping.targetConnectionId.includes("{tenant}")) {
1046
+ patterns.add(mapping.targetConnectionId);
1047
+ }
1048
+ }
1049
+ for (const variable of variablePreviews) {
1050
+ if (typeof variable.value === "string" && variable.value.includes("{tenant}")) {
1051
+ patterns.add(variable.value);
1052
+ }
1053
+ }
1054
+ return Array.from(patterns);
1055
+ }
1056
+ function displayDryRunPlan(plan) {
1057
+ const solutionLabel = plan.solution.endsWith(".zip") ? resolve(plan.solution) : plan.solution;
1058
+ const tenantSuffix = plan.summary.totalTenants === 1 ? "" : "s";
1059
+ console.log(chalk.bold(`Dry run: deploy ${chalk.green(solutionLabel)} to ${plan.summary.totalTenants} tenant${tenantSuffix}`));
1060
+ console.log();
1061
+ for (const wave of plan.waves) {
1062
+ const waitNote = wave.waitAfterCompletionMs
1063
+ ? `, wait after: ${formatDuration(wave.waitAfterCompletionMs)}`
1064
+ : "";
1065
+ console.log(chalk.bold(`Wave ${wave.waveNumber} (${wave.name}) - max parallel: ${wave.maxParallel}${waitNote}`));
1066
+ const table = new Table({
1067
+ head: ["Tenant", "Environment", "Connections", "Variables", "URL Templates", "Validation"],
1068
+ style: { head: ["cyan"] },
1069
+ wordWrap: true,
1070
+ });
1071
+ for (const tenant of wave.tenants) {
1072
+ table.push([
1073
+ tenant.tenantName,
1074
+ new URL(tenant.environmentUrl).hostname,
1075
+ formatConnections(tenant.connectionMappings),
1076
+ formatVariables(tenant.environmentVariables),
1077
+ formatUrlResolutions(tenant.urlResolutions),
1078
+ formatValidation(tenant.validation),
1079
+ ]);
1080
+ }
1081
+ console.log(table.toString());
1082
+ console.log();
1083
+ }
1084
+ if (plan.summary.validationFailedTenants > 0) {
1085
+ console.log(chalk.red(`Validation failed for ${plan.summary.validationFailedTenants} tenant(s). Review errors above before deploying.`));
1086
+ console.log();
1087
+ }
1088
+ console.log(chalk.yellow("No changes were made."));
1089
+ }
1090
+ function formatConnections(connectionMappings) {
1091
+ if (connectionMappings.length === 0) {
1092
+ return "-";
1093
+ }
1094
+ return connectionMappings
1095
+ .map((mapping) => {
1096
+ if (mapping.targetConnectionId === mapping.resolvedTargetConnectionId) {
1097
+ return `${mapping.sourceLogicalName} -> ${mapping.resolvedTargetConnectionId}`;
1098
+ }
1099
+ return `${mapping.sourceLogicalName} -> ${mapping.resolvedTargetConnectionId} (from ${mapping.targetConnectionId})`;
1100
+ })
1101
+ .join("\n");
1102
+ }
1103
+ function formatVariables(environmentVariables) {
1104
+ if (environmentVariables.length === 0) {
1105
+ return "-";
1106
+ }
1107
+ return environmentVariables
1108
+ .map((variable) => {
1109
+ const original = String(variable.value);
1110
+ const resolved = String(variable.resolvedValue);
1111
+ if (original === resolved) {
1112
+ return `${variable.schemaName} -> ${resolved}`;
1113
+ }
1114
+ return `${variable.schemaName} -> ${resolved} (from ${original})`;
1115
+ })
1116
+ .join("\n");
1117
+ }
1118
+ function formatUrlResolutions(urlResolutions) {
1119
+ if (urlResolutions.length === 0) {
1120
+ return "-";
1121
+ }
1122
+ return urlResolutions
1123
+ .map((resolution) => `${resolution.template} -> ${resolution.resolved}`)
1124
+ .join("\n");
1125
+ }
1126
+ function formatValidation(validation) {
1127
+ if (validation.status === "pass") {
1128
+ const warningSuffix = validation.warnings.length > 0 ? ` (${validation.warnings.length} warning)` : "";
1129
+ return `PASS${warningSuffix}`;
1130
+ }
1131
+ if (validation.status === "skipped") {
1132
+ return "SKIPPED";
1133
+ }
1134
+ return `FAIL (${validation.errors.length} error)`;
1135
+ }
1136
+ function formatDuration(durationMs) {
1137
+ if (durationMs % (60 * 60 * 1000) === 0) {
1138
+ return `${durationMs / (60 * 60 * 1000)}h`;
1139
+ }
1140
+ if (durationMs % (60 * 1000) === 0) {
1141
+ return `${durationMs / (60 * 1000)}m`;
1142
+ }
1143
+ if (durationMs % 1000 === 0) {
1144
+ return `${durationMs / 1000}s`;
1145
+ }
1146
+ return `${durationMs}ms`;
1147
+ }
1148
+ /**
1149
+ * Apply URL replacements to a solution ZIP for a specific target tenant.
1150
+ * Returns path to modified ZIP, or null if no replacements were needed.
1151
+ */
1152
+ async function applyUrlReplacements(originalZipPath, detectedUrls, tenant) {
1153
+ if (detectedUrls.length === 0)
1154
+ return null;
1155
+ const JSZip = (await import("jszip")).default;
1156
+ const templater = new UrlTemplater();
1157
+ // Extract target tenant identifier from environment URL
1158
+ // e.g., https://org54870a4d.crm.dynamics.com → org54870a4d
1159
+ const envUrl = new URL(tenant.environmentUrl);
1160
+ const targetTenantId = envUrl.hostname.split(".")[0];
1161
+ const crmRegion = envUrl.hostname.match(/\.(crm\d*)\.dynamics\.com/)?.[1] || "crm";
1162
+ const tenantUrls = {
1163
+ tenant: targetTenantId,
1164
+ sharepoint: `${targetTenantId}.sharepoint.com`,
1165
+ dynamicsCrm: `${targetTenantId}.${crmRegion}.dynamics.com`,
1166
+ onmicrosoft: `${targetTenantId}.onmicrosoft.com`,
1167
+ };
1168
+ // Build replacement map: original URL → resolved URL
1169
+ const replacements = new Map();
1170
+ for (const url of detectedUrls) {
1171
+ const resolved = templater.resolveTemplate(url.templatePattern, tenantUrls);
1172
+ if (resolved !== url.originalUrl) {
1173
+ replacements.set(url.originalUrl, resolved);
1174
+ }
1175
+ }
1176
+ if (replacements.size === 0)
1177
+ return null;
1178
+ // Modify the ZIP
1179
+ const zipBuffer = readFileSync(originalZipPath);
1180
+ const modifiedBuffer = await templater.modifySolution(zipBuffer, replacements, new JSZip());
1181
+ // Write to temp file
1182
+ const tempPath = join(tmpdir(), `pax8-cta-deploy-${randomUUID()}.zip`);
1183
+ writeFileSync(tempPath, modifiedBuffer);
1184
+ return tempPath;
1185
+ }
1186
+ //# sourceMappingURL=deploy.js.map