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.
- package/LICENSE +200 -0
- package/README.md +659 -0
- package/demo-data/solutions/ProductQADemo_1_0_0_2_managed.zip +0 -0
- package/dist/commands/analyze.d.ts +28 -0
- package/dist/commands/analyze.d.ts.map +1 -0
- package/dist/commands/analyze.js +571 -0
- package/dist/commands/analyze.js.map +1 -0
- package/dist/commands/auth.d.ts +18 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +171 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/config.d.ts +34 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +299 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/demo.d.ts +33 -0
- package/dist/commands/demo.d.ts.map +1 -0
- package/dist/commands/demo.js +362 -0
- package/dist/commands/demo.js.map +1 -0
- package/dist/commands/deploy.d.ts +18 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +1186 -0
- package/dist/commands/deploy.js.map +1 -0
- package/dist/commands/deployments/helpers.d.ts +68 -0
- package/dist/commands/deployments/helpers.d.ts.map +1 -0
- package/dist/commands/deployments/helpers.js +414 -0
- package/dist/commands/deployments/helpers.js.map +1 -0
- package/dist/commands/deployments/index.d.ts +24 -0
- package/dist/commands/deployments/index.d.ts.map +1 -0
- package/dist/commands/deployments/index.js +47 -0
- package/dist/commands/deployments/index.js.map +1 -0
- package/dist/commands/deployments/list.d.ts +18 -0
- package/dist/commands/deployments/list.d.ts.map +1 -0
- package/dist/commands/deployments/list.js +97 -0
- package/dist/commands/deployments/list.js.map +1 -0
- package/dist/commands/deployments/show.d.ts +18 -0
- package/dist/commands/deployments/show.d.ts.map +1 -0
- package/dist/commands/deployments/show.js +81 -0
- package/dist/commands/deployments/show.js.map +1 -0
- package/dist/commands/deployments/undo.d.ts +18 -0
- package/dist/commands/deployments/undo.d.ts.map +1 -0
- package/dist/commands/deployments/undo.js +295 -0
- package/dist/commands/deployments/undo.js.map +1 -0
- package/dist/commands/export.d.ts +18 -0
- package/dist/commands/export.d.ts.map +1 -0
- package/dist/commands/export.js +133 -0
- package/dist/commands/export.js.map +1 -0
- package/dist/commands/import.d.ts +18 -0
- package/dist/commands/import.d.ts.map +1 -0
- package/dist/commands/import.js +129 -0
- package/dist/commands/import.js.map +1 -0
- package/dist/commands/init-config.d.ts +26 -0
- package/dist/commands/init-config.d.ts.map +1 -0
- package/dist/commands/init-config.js +123 -0
- package/dist/commands/init-config.js.map +1 -0
- package/dist/commands/init-validation.d.ts +47 -0
- package/dist/commands/init-validation.d.ts.map +1 -0
- package/dist/commands/init-validation.js +339 -0
- package/dist/commands/init-validation.js.map +1 -0
- package/dist/commands/init-wizard.d.ts +25 -0
- package/dist/commands/init-wizard.d.ts.map +1 -0
- package/dist/commands/init-wizard.js +433 -0
- package/dist/commands/init-wizard.js.map +1 -0
- package/dist/commands/init.d.ts +18 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +46 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/resolve-url.d.ts +18 -0
- package/dist/commands/resolve-url.d.ts.map +1 -0
- package/dist/commands/resolve-url.js +126 -0
- package/dist/commands/resolve-url.js.map +1 -0
- package/dist/commands/setup.d.ts +18 -0
- package/dist/commands/setup.d.ts.map +1 -0
- package/dist/commands/setup.js +239 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/commands/solutions/drift-analysis.d.ts +73 -0
- package/dist/commands/solutions/drift-analysis.d.ts.map +1 -0
- package/dist/commands/solutions/drift-analysis.js +416 -0
- package/dist/commands/solutions/drift-analysis.js.map +1 -0
- package/dist/commands/solutions/drift.d.ts +32 -0
- package/dist/commands/solutions/drift.d.ts.map +1 -0
- package/dist/commands/solutions/drift.js +641 -0
- package/dist/commands/solutions/drift.js.map +1 -0
- package/dist/commands/solutions/fix-planner.d.ts +48 -0
- package/dist/commands/solutions/fix-planner.d.ts.map +1 -0
- package/dist/commands/solutions/fix-planner.js +43 -0
- package/dist/commands/solutions/fix-planner.js.map +1 -0
- package/dist/commands/solutions/helpers.d.ts +35 -0
- package/dist/commands/solutions/helpers.d.ts.map +1 -0
- package/dist/commands/solutions/helpers.js +54 -0
- package/dist/commands/solutions/helpers.js.map +1 -0
- package/dist/commands/solutions/index.d.ts +18 -0
- package/dist/commands/solutions/index.d.ts.map +1 -0
- package/dist/commands/solutions/index.js +30 -0
- package/dist/commands/solutions/index.js.map +1 -0
- package/dist/commands/solutions/list.d.ts +18 -0
- package/dist/commands/solutions/list.d.ts.map +1 -0
- package/dist/commands/solutions/list.js +174 -0
- package/dist/commands/solutions/list.js.map +1 -0
- package/dist/commands/solutions/remove.d.ts +18 -0
- package/dist/commands/solutions/remove.d.ts.map +1 -0
- package/dist/commands/solutions/remove.js +137 -0
- package/dist/commands/solutions/remove.js.map +1 -0
- package/dist/commands/solutions/risk-calculator.d.ts +33 -0
- package/dist/commands/solutions/risk-calculator.d.ts.map +1 -0
- package/dist/commands/solutions/risk-calculator.js +79 -0
- package/dist/commands/solutions/risk-calculator.js.map +1 -0
- package/dist/commands/solutions/show.d.ts +18 -0
- package/dist/commands/solutions/show.d.ts.map +1 -0
- package/dist/commands/solutions/show.js +165 -0
- package/dist/commands/solutions/show.js.map +1 -0
- package/dist/commands/status.d.ts +18 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +573 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/telemetry.d.ts +18 -0
- package/dist/commands/telemetry.d.ts.map +1 -0
- package/dist/commands/telemetry.js +85 -0
- package/dist/commands/telemetry.js.map +1 -0
- package/dist/commands/tenants/health.d.ts +18 -0
- package/dist/commands/tenants/health.d.ts.map +1 -0
- package/dist/commands/tenants/health.js +172 -0
- package/dist/commands/tenants/health.js.map +1 -0
- package/dist/commands/tenants/helpers.d.ts +44 -0
- package/dist/commands/tenants/helpers.d.ts.map +1 -0
- package/dist/commands/tenants/helpers.js +72 -0
- package/dist/commands/tenants/helpers.js.map +1 -0
- package/dist/commands/tenants/index.d.ts +19 -0
- package/dist/commands/tenants/index.d.ts.map +1 -0
- package/dist/commands/tenants/index.js +39 -0
- package/dist/commands/tenants/index.js.map +1 -0
- package/dist/commands/tenants/inspect.d.ts +18 -0
- package/dist/commands/tenants/inspect.d.ts.map +1 -0
- package/dist/commands/tenants/inspect.js +176 -0
- package/dist/commands/tenants/inspect.js.map +1 -0
- package/dist/commands/tenants/list.d.ts +18 -0
- package/dist/commands/tenants/list.d.ts.map +1 -0
- package/dist/commands/tenants/list.js +144 -0
- package/dist/commands/tenants/list.js.map +1 -0
- package/dist/commands/tenants/manage.d.ts +20 -0
- package/dist/commands/tenants/manage.d.ts.map +1 -0
- package/dist/commands/tenants/manage.js +206 -0
- package/dist/commands/tenants/manage.js.map +1 -0
- package/dist/commands/tenants/show.d.ts +18 -0
- package/dist/commands/tenants/show.d.ts.map +1 -0
- package/dist/commands/tenants/show.js +191 -0
- package/dist/commands/tenants/show.js.map +1 -0
- package/dist/commands/validate.d.ts +18 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +536 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +258 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/auth.d.ts +51 -0
- package/dist/lib/auth.d.ts.map +1 -0
- package/dist/lib/auth.js +153 -0
- package/dist/lib/auth.js.map +1 -0
- package/dist/lib/banner.d.ts +19 -0
- package/dist/lib/banner.d.ts.map +1 -0
- package/dist/lib/banner.js +78 -0
- package/dist/lib/banner.js.map +1 -0
- package/dist/lib/command-wrapper.d.ts +56 -0
- package/dist/lib/command-wrapper.d.ts.map +1 -0
- package/dist/lib/command-wrapper.js +71 -0
- package/dist/lib/command-wrapper.js.map +1 -0
- package/dist/lib/credentials.d.ts +56 -0
- package/dist/lib/credentials.d.ts.map +1 -0
- package/dist/lib/credentials.js +146 -0
- package/dist/lib/credentials.js.map +1 -0
- package/dist/lib/demo-banner.d.ts +15 -0
- package/dist/lib/demo-banner.d.ts.map +1 -0
- package/dist/lib/demo-banner.js +33 -0
- package/dist/lib/demo-banner.js.map +1 -0
- package/dist/lib/error-handler.d.ts +51 -0
- package/dist/lib/error-handler.d.ts.map +1 -0
- package/dist/lib/error-handler.js +458 -0
- package/dist/lib/error-handler.js.map +1 -0
- package/dist/lib/errors.d.ts +61 -0
- package/dist/lib/errors.d.ts.map +1 -0
- package/dist/lib/errors.js +168 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/formatters.d.ts +55 -0
- package/dist/lib/formatters.d.ts.map +1 -0
- package/dist/lib/formatters.js +163 -0
- package/dist/lib/formatters.js.map +1 -0
- package/dist/lib/graph-client.d.ts +74 -0
- package/dist/lib/graph-client.d.ts.map +1 -0
- package/dist/lib/graph-client.js +231 -0
- package/dist/lib/graph-client.js.map +1 -0
- package/dist/lib/input.d.ts +22 -0
- package/dist/lib/input.d.ts.map +1 -0
- package/dist/lib/input.js +120 -0
- package/dist/lib/input.js.map +1 -0
- package/dist/lib/interactive-wizard.d.ts +26 -0
- package/dist/lib/interactive-wizard.d.ts.map +1 -0
- package/dist/lib/interactive-wizard.js +550 -0
- package/dist/lib/interactive-wizard.js.map +1 -0
- package/dist/lib/oss-surface.d.ts +21 -0
- package/dist/lib/oss-surface.d.ts.map +1 -0
- package/dist/lib/oss-surface.js +29 -0
- package/dist/lib/oss-surface.js.map +1 -0
- package/dist/lib/output.d.ts +74 -0
- package/dist/lib/output.d.ts.map +1 -0
- package/dist/lib/output.js +156 -0
- package/dist/lib/output.js.map +1 -0
- package/dist/lib/picker.d.ts +75 -0
- package/dist/lib/picker.d.ts.map +1 -0
- package/dist/lib/picker.js +115 -0
- package/dist/lib/picker.js.map +1 -0
- package/dist/lib/repl.d.ts +19 -0
- package/dist/lib/repl.d.ts.map +1 -0
- package/dist/lib/repl.js +158 -0
- package/dist/lib/repl.js.map +1 -0
- package/dist/lib/spinner.d.ts +41 -0
- package/dist/lib/spinner.d.ts.map +1 -0
- package/dist/lib/spinner.js +126 -0
- package/dist/lib/spinner.js.map +1 -0
- package/dist/lib/telemetry.d.ts +96 -0
- package/dist/lib/telemetry.d.ts.map +1 -0
- package/dist/lib/telemetry.js +367 -0
- package/dist/lib/telemetry.js.map +1 -0
- 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
|