@microsoft/managed-apps-cli 0.9.2 → 0.11.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 (188) hide show
  1. package/README.md +18 -342
  2. package/dist/ArgumentProvider.d.ts.map +1 -1
  3. package/dist/ArgumentProvider.js +8 -1
  4. package/dist/ArgumentProvider.js.map +1 -1
  5. package/dist/Authentication/NodeMsalAuthenticationProvider.js +20 -1
  6. package/dist/Authentication/NodeMsalAuthenticationProvider.js.map +1 -1
  7. package/dist/Cli.d.ts.map +1 -1
  8. package/dist/Cli.js +15 -3
  9. package/dist/Cli.js.map +1 -1
  10. package/dist/Constants/CommandRegistry.d.ts +1 -2
  11. package/dist/Constants/CommandRegistry.d.ts.map +1 -1
  12. package/dist/Constants/CommandRegistry.js +26 -45
  13. package/dist/Constants/CommandRegistry.js.map +1 -1
  14. package/dist/Constants/HelpStrings.d.ts +12 -8
  15. package/dist/Constants/HelpStrings.d.ts.map +1 -1
  16. package/dist/Constants/HelpStrings.js +98 -80
  17. package/dist/Constants/HelpStrings.js.map +1 -1
  18. package/dist/Errors/GitHubErrorMap.d.ts +43 -14
  19. package/dist/Errors/GitHubErrorMap.d.ts.map +1 -1
  20. package/dist/Errors/GitHubErrorMap.js +189 -147
  21. package/dist/Errors/GitHubErrorMap.js.map +1 -1
  22. package/dist/Logger/CliLogger.d.ts +2 -0
  23. package/dist/Logger/CliLogger.d.ts.map +1 -1
  24. package/dist/Logger/CliLogger.js +12 -3
  25. package/dist/Logger/CliLogger.js.map +1 -1
  26. package/dist/Logger/ConsoleWriter.d.ts +2 -0
  27. package/dist/Logger/ConsoleWriter.d.ts.map +1 -1
  28. package/dist/Logger/ConsoleWriter.js +17 -7
  29. package/dist/Logger/ConsoleWriter.js.map +1 -1
  30. package/dist/Types/Argument.types.d.ts +1 -0
  31. package/dist/Types/Argument.types.d.ts.map +1 -1
  32. package/dist/Types/Command.types.d.ts +6 -0
  33. package/dist/Types/Command.types.d.ts.map +1 -1
  34. package/dist/Types/Command.types.js +8 -0
  35. package/dist/Types/Command.types.js.map +1 -1
  36. package/dist/Utils/BuildVerbDescriptionTable.d.ts.map +1 -1
  37. package/dist/Utils/BuildVerbDescriptionTable.js +6 -2
  38. package/dist/Utils/BuildVerbDescriptionTable.js.map +1 -1
  39. package/dist/Utils/ConnectorIds.d.ts +12 -0
  40. package/dist/Utils/ConnectorIds.d.ts.map +1 -0
  41. package/dist/Utils/ConnectorIds.js +20 -0
  42. package/dist/Utils/ConnectorIds.js.map +1 -0
  43. package/dist/Utils/DcfRecovery.d.ts +7 -7
  44. package/dist/Utils/DcfRecovery.d.ts.map +1 -1
  45. package/dist/Utils/DcfRecovery.js +17 -37
  46. package/dist/Utils/DcfRecovery.js.map +1 -1
  47. package/dist/Utils/DownloadTemplate.d.ts +45 -0
  48. package/dist/Utils/DownloadTemplate.d.ts.map +1 -0
  49. package/dist/Utils/DownloadTemplate.js +161 -0
  50. package/dist/Utils/DownloadTemplate.js.map +1 -0
  51. package/dist/Utils/EnvVars.d.ts +1 -1
  52. package/dist/Utils/EnvVars.d.ts.map +1 -1
  53. package/dist/Utils/EnvVars.js.map +1 -1
  54. package/dist/Utils/EnvironmentRoutingUtils.d.ts +20 -1
  55. package/dist/Utils/EnvironmentRoutingUtils.d.ts.map +1 -1
  56. package/dist/Utils/EnvironmentRoutingUtils.js +78 -3
  57. package/dist/Utils/EnvironmentRoutingUtils.js.map +1 -1
  58. package/dist/Utils/GitErrorClassifier.d.ts +40 -0
  59. package/dist/Utils/GitErrorClassifier.d.ts.map +1 -0
  60. package/dist/Utils/GitErrorClassifier.js +120 -0
  61. package/dist/Utils/GitErrorClassifier.js.map +1 -0
  62. package/dist/Utils/GitRunner.d.ts +107 -0
  63. package/dist/Utils/GitRunner.d.ts.map +1 -0
  64. package/dist/Utils/GitRunner.js +89 -0
  65. package/dist/Utils/GitRunner.js.map +1 -0
  66. package/dist/Utils/GitUtils.d.ts +12 -0
  67. package/dist/Utils/GitUtils.d.ts.map +1 -1
  68. package/dist/Utils/GitUtils.js +83 -20
  69. package/dist/Utils/GitUtils.js.map +1 -1
  70. package/dist/Utils/GitVersionChecker.d.ts +38 -0
  71. package/dist/Utils/GitVersionChecker.d.ts.map +1 -0
  72. package/dist/Utils/GitVersionChecker.js +64 -0
  73. package/dist/Utils/GitVersionChecker.js.map +1 -0
  74. package/dist/Utils/MaafAppConfig.d.ts +22 -0
  75. package/dist/Utils/MaafAppConfig.d.ts.map +1 -0
  76. package/dist/Utils/MaafAppConfig.js +32 -0
  77. package/dist/Utils/MaafAppConfig.js.map +1 -0
  78. package/dist/Utils/NodeVersionChecker.d.ts +3 -6
  79. package/dist/Utils/NodeVersionChecker.d.ts.map +1 -1
  80. package/dist/Utils/NodeVersionChecker.js +9 -26
  81. package/dist/Utils/NodeVersionChecker.js.map +1 -1
  82. package/dist/Utils/Paging.d.ts +6 -0
  83. package/dist/Utils/Paging.d.ts.map +1 -1
  84. package/dist/Utils/Paging.js +36 -1
  85. package/dist/Utils/Paging.js.map +1 -1
  86. package/dist/Utils/PinCli.d.ts +28 -0
  87. package/dist/Utils/PinCli.d.ts.map +1 -0
  88. package/dist/Utils/PinCli.js +58 -0
  89. package/dist/Utils/PinCli.js.map +1 -0
  90. package/dist/Utils/RepoDetails.d.ts +29 -0
  91. package/dist/Utils/RepoDetails.d.ts.map +1 -0
  92. package/dist/Utils/RepoDetails.js +37 -0
  93. package/dist/Utils/RepoDetails.js.map +1 -0
  94. package/dist/Utils/RepoOption.d.ts +35 -0
  95. package/dist/Utils/RepoOption.d.ts.map +1 -0
  96. package/dist/Utils/RepoOption.js +36 -0
  97. package/dist/Utils/RepoOption.js.map +1 -0
  98. package/dist/Utils/ResolveCommand.d.ts.map +1 -1
  99. package/dist/Utils/ResolveCommand.js +6 -4
  100. package/dist/Utils/ResolveCommand.js.map +1 -1
  101. package/dist/Utils/Spinner.d.ts.map +1 -1
  102. package/dist/Utils/Spinner.js +3 -0
  103. package/dist/Utils/Spinner.js.map +1 -1
  104. package/dist/Verbs/AddAction.js +6 -6
  105. package/dist/Verbs/AddAction.js.map +1 -1
  106. package/dist/Verbs/AddConnector.d.ts +6 -4
  107. package/dist/Verbs/AddConnector.d.ts.map +1 -1
  108. package/dist/Verbs/AddConnector.js +54 -14
  109. package/dist/Verbs/AddConnector.js.map +1 -1
  110. package/dist/Verbs/AddConnectorFlows.d.ts +29 -16
  111. package/dist/Verbs/AddConnectorFlows.d.ts.map +1 -1
  112. package/dist/Verbs/AddConnectorFlows.js +73 -25
  113. package/dist/Verbs/AddConnectorFlows.js.map +1 -1
  114. package/dist/Verbs/AddDataSource.d.ts +3 -4
  115. package/dist/Verbs/AddDataSource.d.ts.map +1 -1
  116. package/dist/Verbs/AddDataSource.js +9 -20
  117. package/dist/Verbs/AddDataSource.js.map +1 -1
  118. package/dist/Verbs/AddTable.js +7 -7
  119. package/dist/Verbs/AddTable.js.map +1 -1
  120. package/dist/Verbs/AuthStatus.d.ts.map +1 -1
  121. package/dist/Verbs/AuthStatus.js +7 -4
  122. package/dist/Verbs/AuthStatus.js.map +1 -1
  123. package/dist/Verbs/BuildMaafApp.js +1 -1
  124. package/dist/Verbs/BuildMaafApp.js.map +1 -1
  125. package/dist/Verbs/ConnectorHelpers.d.ts +5 -4
  126. package/dist/Verbs/ConnectorHelpers.d.ts.map +1 -1
  127. package/dist/Verbs/ConnectorHelpers.js +14 -9
  128. package/dist/Verbs/ConnectorHelpers.js.map +1 -1
  129. package/dist/Verbs/CreateMaafApp.d.ts +25 -42
  130. package/dist/Verbs/CreateMaafApp.d.ts.map +1 -1
  131. package/dist/Verbs/CreateMaafApp.js +346 -268
  132. package/dist/Verbs/CreateMaafApp.js.map +1 -1
  133. package/dist/Verbs/DeployMaafApp.d.ts.map +1 -1
  134. package/dist/Verbs/DeployMaafApp.js +4 -4
  135. package/dist/Verbs/DeployMaafApp.js.map +1 -1
  136. package/dist/Verbs/Dev.js +1 -1
  137. package/dist/Verbs/Dev.js.map +1 -1
  138. package/dist/Verbs/GetMaafApp.d.ts.map +1 -1
  139. package/dist/Verbs/GetMaafApp.js +10 -0
  140. package/dist/Verbs/GetMaafApp.js.map +1 -1
  141. package/dist/Verbs/GetMaafBuildStatus.d.ts.map +1 -1
  142. package/dist/Verbs/GetMaafBuildStatus.js +52 -3
  143. package/dist/Verbs/GetMaafBuildStatus.js.map +1 -1
  144. package/dist/Verbs/GetSettings.js +1 -1
  145. package/dist/Verbs/GetSettings.js.map +1 -1
  146. package/dist/Verbs/Init.d.ts.map +1 -1
  147. package/dist/Verbs/Init.js +183 -60
  148. package/dist/Verbs/Init.js.map +1 -1
  149. package/dist/Verbs/ListConnectorActions.js +12 -9
  150. package/dist/Verbs/ListConnectorActions.js.map +1 -1
  151. package/dist/Verbs/ListConnectors.d.ts.map +1 -1
  152. package/dist/Verbs/ListConnectors.js +3 -2
  153. package/dist/Verbs/ListConnectors.js.map +1 -1
  154. package/dist/Verbs/ListMaafApps.js +3 -3
  155. package/dist/Verbs/ListMaafApps.js.map +1 -1
  156. package/dist/Verbs/MaafAppPermissions.d.ts.map +1 -1
  157. package/dist/Verbs/MaafAppPermissions.js +89 -17
  158. package/dist/Verbs/MaafAppPermissions.js.map +1 -1
  159. package/dist/Verbs/Pack.d.ts +1 -1
  160. package/dist/Verbs/RefreshDataSource.js +1 -1
  161. package/dist/Verbs/RefreshDataSource.js.map +1 -1
  162. package/dist/Verbs/RefreshGitAuth.d.ts.map +1 -1
  163. package/dist/Verbs/RefreshGitAuth.js +3 -15
  164. package/dist/Verbs/RefreshGitAuth.js.map +1 -1
  165. package/dist/Verbs/{DeleteDataSource.d.ts → RemoveDataSource.d.ts} +2 -2
  166. package/dist/Verbs/RemoveDataSource.d.ts.map +1 -0
  167. package/dist/Verbs/RemoveDataSource.js +158 -0
  168. package/dist/Verbs/RemoveDataSource.js.map +1 -0
  169. package/dist/Verbs/SetSetting.js +2 -2
  170. package/dist/Verbs/SetSetting.js.map +1 -1
  171. package/dist/Verbs/ShareLinkList.js +1 -1
  172. package/dist/Verbs/ShareLinkList.js.map +1 -1
  173. package/dist/Verbs/ShowMaafApp.d.ts +6 -0
  174. package/dist/Verbs/ShowMaafApp.d.ts.map +1 -1
  175. package/dist/Verbs/ShowMaafApp.js +10 -2
  176. package/dist/Verbs/ShowMaafApp.js.map +1 -1
  177. package/dist/Verbs/VerbConstants.d.ts +20 -23
  178. package/dist/Verbs/VerbConstants.d.ts.map +1 -1
  179. package/dist/Verbs/VerbConstants.js +20 -23
  180. package/dist/Verbs/VerbConstants.js.map +1 -1
  181. package/dist/Verbs/index.d.ts +1 -1
  182. package/dist/Verbs/index.d.ts.map +1 -1
  183. package/dist/Verbs/index.js +1 -1
  184. package/dist/Verbs/index.js.map +1 -1
  185. package/package.json +11 -9
  186. package/dist/Verbs/DeleteDataSource.d.ts.map +0 -1
  187. package/dist/Verbs/DeleteDataSource.js +0 -105
  188. package/dist/Verbs/DeleteDataSource.js.map +0 -1
@@ -4,9 +4,7 @@
4
4
  import { createAsync, getPlayerServiceConfig, updateEnvironmentName, writeRepoConfig, } from '@microsoft/managed-apps-actions';
5
5
  import { AppConfigFileName } from '@microsoft/managed-apps-common/config';
6
6
  import { regionToCloudSafe } from '@microsoft/managed-apps-common/services';
7
- import { execFileSync } from 'child_process';
8
7
  import * as fs from 'fs';
9
- import { downloadTemplate } from 'giget';
10
8
  import * as os from 'os';
11
9
  import * as path from 'path';
12
10
  import { getCliLogger, getSettings } from '../CliSettings.js';
@@ -16,38 +14,17 @@ import { HelpStrings } from '../Constants/HelpStrings.js';
16
14
  import { AppError, ConfigurationError, UsageError } from '../Errors/CliError.js';
17
15
  import { printInfo, printJsonLine, printJsonSuccess, printSuccess, printWarning, } from '../Utils/ConsoleOutput.js';
18
16
  import { withGitHubDcfRecoveryAsync } from '../Utils/DcfRecovery.js';
19
- import { startPdeRoutingAsync } from '../Utils/EnvironmentRoutingUtils.js';
17
+ import { downloadTemplate, parseGithubTemplate } from '../Utils/DownloadTemplate.js';
18
+ import { awaitRoutingEnvironmentAsync, startPdeRoutingAsync, } from '../Utils/EnvironmentRoutingUtils.js';
20
19
  import { buildGcmConfig } from '../Utils/GitCredentialConfig.js';
20
+ import { runGit } from '../Utils/GitRunner.js';
21
+ import { ensureGitInstalled } from '../Utils/GitVersionChecker.js';
22
+ import { buildMaafAppConfig } from '../Utils/MaafAppConfig.js';
23
+ import { pinCliIntoAppPackageJson } from '../Utils/PinCli.js';
24
+ import { parseRepoOption } from '../Utils/RepoOption.js';
25
+ import { CliSpinner } from '../Utils/Spinner.js';
21
26
  import { CreateMaafAppArguments } from './VerbConstants.js';
22
27
  const DEFAULT_TEMPLATE = 'github:microsoft/Managed-Apps/templates/vite8';
23
- /**
24
- * Sentinel value for `--repo` that selects the no-source-control flow: just create the
25
- * server-side app record, write `ms.config.json` with `repoType: 'none'`, and skip every
26
- * local-setup step (git init/clone, template overlay, GCM config) plus the server-side
27
- * GRS auto-provisioning. Comparison is case-insensitive — `none`, `None`, `NONE` all
28
- * select this flow. Any other non-empty `--repo` value is treated as a remote URL.
29
- */
30
- const REPO_NONE_SENTINEL = 'none';
31
- /**
32
- * Maps the raw `--repo` flag value to a `(repoType, remoteUrl)` pair.
33
- * - omitted / empty → `native` (default GRS-backed flow)
34
- * - `none` (case-insensitive) → `none` (no GRS, no local setup)
35
- * - any other non-empty value → `github` (external GitHub binding — `*.ghe.com` or `github.com`;
36
- * value flows through as `remoteUrl` to the action and the eventual git clone)
37
- *
38
- * Runtime invariant: `remoteUrl` is set iff `repoType === 'github'`. Consumers
39
- * branch on `repoType` and assert `remoteUrl!` at github use sites — see
40
- * `RepoSelection` for why narrowing is not automatic.
41
- */
42
- export function parseRepoOption(rawRepoValue) {
43
- if (rawRepoValue === undefined || rawRepoValue === '') {
44
- return { repoType: 'native', remoteUrl: undefined };
45
- }
46
- if (rawRepoValue.trim().toLowerCase() === REPO_NONE_SENTINEL) {
47
- return { repoType: 'none', remoteUrl: undefined };
48
- }
49
- return { repoType: 'github', remoteUrl: rawRepoValue };
50
- }
51
28
  /**
52
29
  * Creates a new MAAF app and (where applicable) sets up local source control.
53
30
  *
@@ -124,43 +101,60 @@ export async function createMaafAppAsync(argumentProvider) {
124
101
  // wipe local files). To bind a non-empty directory to a MAAF app, use `ms app
125
102
  // init` instead.
126
103
  //
127
- // --repo none path: no git, no clone ms.config.json and the template are
128
- // written locally. The preflight reduces to (1) target dir must be writable
129
- // so the config and template writes won't fail post-create, and (2) the dir
130
- // must be empty (template scaffolding requires a clean target, same as the
131
- // other flows).
104
+ // --repo none path: no source-control git (no `git init`, no `git clone` of a
105
+ // user-supplied remote). Git itself is still required, because the template
106
+ // download is a sparse `git clone` of the github-hosted template repo
107
+ // hence `ensureGitInstalled` runs unconditionally below.
132
108
  // ──────────────────────────────────────────────────────────────────────────
133
- if (!jsonMode) {
109
+ const usePreflightSpinner = !jsonMode && process.stdout.isTTY;
110
+ if (!jsonMode && !usePreflightSpinner) {
134
111
  printInfo('Running preflight checks...');
135
112
  }
136
- // --repo none is incompatible with --repository-id: the former opts out of any
137
- // platform-managed git binding, the latter forces one. Fail fast before any
138
- // server call so we don't half-bind the app.
139
- if (repoType === 'none' && repositoryId) {
140
- throw new UsageError("'--repo none' is incompatible with '--repository-id'. " +
141
- "Use '--repo none' for an app with no git binding, or omit '--repo' (or pass a repo URL) to use a backing repository.");
142
- }
143
- ensureTemplateSourceValid(template);
144
- if (repoType !== 'none') {
113
+ // Spinner runs only in TTY interactive mode; --json keeps stdout clean and
114
+ // non-TTY (CI, piped output) uses plain prose so log files stay readable.
115
+ // Most preflight checks are sync, so the spinner frame can pause while a
116
+ // check runs; we still update the status label before each check so users
117
+ // always see the current step. The try/finally guarantees we restore the
118
+ // cursor even if a preflight check throws.
119
+ const preflightSpinner = usePreflightSpinner ? new CliSpinner() : null;
120
+ preflightSpinner?.start('Running preflight checks...');
121
+ try {
122
+ // --repo none is incompatible with --repository-id: the former opts out of any
123
+ // platform-managed git binding, the latter forces one. Fail fast before any
124
+ // server call so we don't half-bind the app.
125
+ if (repoType === 'none' && repositoryId) {
126
+ throw new UsageError("'--repo none' is incompatible with '--repository-id'. " +
127
+ "Use '--repo none' for an app with no git binding, or omit '--repo' (or pass a repo URL) to use a backing repository.");
128
+ }
129
+ preflightSpinner?.update('Validating template source...');
130
+ ensureTemplateSourceValid(template);
131
+ preflightSpinner?.update('Checking git installation...');
145
132
  ensureGitInstalled();
133
+ preflightSpinner?.update('Verifying target directory is writable...');
134
+ ensureTargetDirectoryWritable(appDir);
135
+ if (repoType === 'github') {
136
+ // RepoSelection is a flat interface (not a discriminated union), so `remoteUrl`
137
+ // doesn't auto-narrow to `string` here even though the github branch guarantees it.
138
+ preflightSpinner?.update('Verifying target directory is empty...');
139
+ ensureRepoTargetDirectoryEmpty(appDir);
140
+ // Pre-network: cheap partial clone into a temp dir + ls-tree HEAD to detect
141
+ // a non-empty remote before createAsync runs. Catches the rejection case
142
+ // without leaving an orphaned server-side app or scaffolded files in
143
+ // appDir. Cheapest probe shape: --depth=1 --filter=blob:none --no-checkout
144
+ // pulls refs + the tip commit + its tree(s) only (no blob bytes, no working
145
+ // tree). On any probe error (network, auth, server doesn't support partial
146
+ // clone, etc.) we swallow and let the subsequent cloneRepo emit its
147
+ // friendlier error — see ensureExternalRepoIsEmpty for the rationale.
148
+ preflightSpinner?.update(`Verifying ${remoteUrl} is empty...`);
149
+ ensureExternalRepoIsEmpty(remoteUrl);
150
+ }
151
+ else {
152
+ preflightSpinner?.update('Verifying target directory is empty...');
153
+ ensureTargetDirectoryEmpty(appDir);
154
+ }
146
155
  }
147
- ensureTargetDirectoryWritable(appDir);
148
- if (repoType === 'github') {
149
- // RepoSelection is a flat interface (not a discriminated union), so `remoteUrl`
150
- // doesn't auto-narrow to `string` here even though the github branch guarantees it.
151
- ensureRepoTargetDirectoryEmpty(appDir);
152
- // Pre-network: cheap partial clone into a temp dir + ls-tree HEAD to detect
153
- // a non-empty remote before createAsync runs. Catches the rejection case
154
- // without leaving an orphaned server-side app or scaffolded files in
155
- // appDir. Cheapest probe shape: --depth=1 --filter=blob:none --no-checkout
156
- // pulls refs + the tip commit + its tree(s) only (no blob bytes, no working
157
- // tree). On any probe error (network, auth, server doesn't support partial
158
- // clone, etc.) we swallow and let the subsequent cloneRepo emit its
159
- // friendlier error — see ensureExternalRepoIsEmpty for the rationale.
160
- ensureExternalRepoIsEmpty(remoteUrl);
161
- }
162
- else {
163
- ensureTargetDirectoryEmpty(appDir);
156
+ finally {
157
+ preflightSpinner?.stop();
164
158
  }
165
159
  // ─── End preflight ────────────────────────────────────────────────────────
166
160
  // The debug env-var override (MS_CLI_MAAF_DEBUG_ENVIRONMENT_ID) is honored by ArgumentProvider
@@ -184,9 +178,10 @@ export async function createMaafAppAsync(argumentProvider) {
184
178
  /* ignorePrompt */ true);
185
179
  const buildEntryPoint = await argumentProvider.getOption('buildEntryPoint',
186
180
  /* ignorePrompt */ true);
187
- // Explicit branch keeps the non-null read of routingEnvPromise reachable only when
188
- // preResolvedEnvironmentId is undefined i.e. exactly when routingEnvPromise is defined.
189
- const environmentId = preResolvedEnvironmentId ?? (await routingEnvPromise);
181
+ // Shared environment-routing logic between CreateMaafApp and Init.
182
+ // `awaitRoutingEnvironmentAsync` prints a `--environment-id` hint on any
183
+ // failure HTTP error codes, `operationSuccess: false`, and exceptions.
184
+ const environmentId = await awaitRoutingEnvironmentAsync(preResolvedEnvironmentId, routingEnvPromise);
190
185
  updateEnvironmentName(environmentId);
191
186
  // Re-check the directory invariant right before the server call. The same
192
187
  // check ran in preflight, but the routing-env resolution between then and
@@ -319,33 +314,49 @@ export async function createMaafAppAsync(argumentProvider) {
319
314
  if (!jsonMode) {
320
315
  printInfo(`Fetching template '${template}' into ${appDir}...`);
321
316
  }
322
- await downloadTemplate(template, { dir: appDir, force: true });
323
- const config = {
317
+ downloadTemplate(template, { dir: appDir });
318
+ // Pin the scaffolding CLI into the app so a later package install provides a
319
+ // project-local `ms` — which `npx ms` / `pnpm exec` / `bun x` resolve ahead of
320
+ // any globally-installed CLI — at the exact version the app was created with.
321
+ // Runs for every repo flow (the template, and therefore its package.json,
322
+ // lands via downloadTemplate above regardless of `--repo`).
323
+ pinCliIntoAppPackageJson(appDir, jsonMode);
324
+ const config = buildMaafAppConfig({
324
325
  appId: app.id,
325
326
  appDisplayName: app.displayName,
326
327
  description: app.description,
327
328
  environmentId: app.environmentId,
329
+ repoType,
330
+ cloud: argumentProvider.getCloud(),
331
+ buildPath,
332
+ buildCommand,
333
+ buildEntryPoint,
328
334
  // Only the `native` flow has a platform-managed git binding. `github` tracks
329
335
  // external binding server-side via `externalRepo`, and `none` intentionally has
330
336
  // no platform-managed binding. Omit `repositoryId` for both so downstream verbs
331
337
  // key off `repoType` instead of a placeholder value.
332
- ...(repoType === 'native' && { repositoryId: app.repositoryId }),
333
- repoType,
338
+ repositoryId: repoType === 'native' ? app.repositoryId : undefined,
334
339
  // externalRepoUrl is persisted only for github-bound apps so that post-create verbs
335
340
  // (build, deploy) know which GitHub host to authenticate against during inline DCF
336
341
  // recovery without re-prompting the user for the URL.
337
- ...(repoType === 'github' && { externalRepoUrl: remoteUrl }),
338
- cloud: argumentProvider.getCloud(),
339
- buildPath: buildPath ?? './dist',
340
- buildCommand: buildCommand ?? 'npm run build',
341
- buildEntryPoint: buildEntryPoint ?? 'index.html',
342
- };
342
+ externalRepoUrl: repoType === 'github' ? remoteUrl : undefined,
343
+ });
343
344
  await writeRepoConfig(path.join(appDir, AppConfigFileName), config);
344
345
  // Auto commit + push for git-backed flows (native + github). The `none` flow
345
346
  // has no remote, so it's skipped. Push failure is non-fatal — see the warning
346
347
  // path in commitAndPushScaffoldAsync.
347
348
  if (repoType !== 'none') {
348
- pushOutcome = await commitAndPushScaffoldAsync(appDir, jsonMode);
349
+ // Classify the push only for the native (GRS) flow: we own its GCM browser
350
+ // OAuth credentials, so we can label an auth/network failure correctly. The
351
+ // github flow uses the developer's own credentials (gh / SSH / PAT) we don't
352
+ // control, so it's left unclassified. Unlike fetch, push is non-fatal: the
353
+ // classified error is caught and turned into a PushOutcome, so its exit code
354
+ // never propagates. Classification only relabels the cause-specific message —
355
+ // surfaced as the human-mode warning and carried in the --json error payload.
356
+ const pushClassify = repoType === 'native' && cloneUrl
357
+ ? { action: `pushing to ${gitHostLabel(cloneUrl)}` }
358
+ : undefined;
359
+ pushOutcome = await commitAndPushScaffoldAsync(appDir, jsonMode, pushClassify);
349
360
  }
350
361
  }
351
362
  catch (setupError) {
@@ -355,7 +366,21 @@ export async function createMaafAppAsync(argumentProvider) {
355
366
  if (setupError instanceof UsageError) {
356
367
  throw setupError;
357
368
  }
358
- throw new AppError(`App '${app.displayName}' was created, but local setup failed: ${setupError instanceof Error ? setupError.message : String(setupError)}\n` + buildOrphanedAppRecoveryHint(app.id, app.environmentId));
369
+ const detail = setupError instanceof Error ? setupError.message : String(setupError);
370
+ const message = `App '${app.displayName}' was created, but local setup failed: ${detail}\n` +
371
+ buildOrphanedAppRecoveryHint(app.id, app.environmentId);
372
+ // Re-message a typed AppError in place rather than re-wrapping it, so the
373
+ // failure category survives the orphan-hint prefix intact: a classified
374
+ // AuthenticationError keeps its exit code (3), name, errorData, AND its
375
+ // subclass identity (`instanceof AuthenticationError` still holds for any
376
+ // future handler that branches on the class). Constructing a fresh AppError
377
+ // would downgrade everything to exit 1 and erase the subclass. The original
378
+ // message is preserved as `detail` inside the new message, so nothing is lost.
379
+ if (setupError instanceof AppError) {
380
+ setupError.message = message;
381
+ throw setupError;
382
+ }
383
+ throw new AppError(message);
359
384
  }
360
385
  if (jsonMode) {
361
386
  // `appId` is the bare GUID returned by the server (`MaafAppDefinition.id`) and
@@ -392,17 +417,22 @@ export async function createMaafAppAsync(argumentProvider) {
392
417
  * Failure is non-fatal: prints a warning with the retry command and returns
393
418
  * `{ success: false, ... }`. The server-side app and local scaffold are unchanged.
394
419
  *
395
- * `stdio: ['ignore', 'ignore', 'inherit']`:
396
- * - stdin ignored: child can't prompt for input (would block forever).
397
- * - stdout ignored: discards git's progress prose so pretty output stays clean.
398
- * - stderr inherited: git's actual error messages reach the user's terminal
399
- * so they see git's own context alongside our warning.
420
+ * All three git commands go through {@link runGit}, so stdin is ignored (the
421
+ * child can never block waiting for input) and stdout is discarded (git's
422
+ * progress prose stays out of our pretty output). Stderr handling follows the
423
+ * runGit policy: the un-classified `add`/`commit` steps re-emit git's stderr on
424
+ * failure, and the `push` step either streams stderr live (un-classified github
425
+ * flow, `network: true`) or pipes it for classification (native flow, when a
426
+ * `pushClassify` context is supplied) — either way git's own error context
427
+ * reaches the user alongside our warning. Note that a classified push error is
428
+ * caught here, so (unlike fetch) its exit code never propagates; classification
429
+ * only shapes the warning text and the `--json` error payload.
400
430
  *
401
431
  * Commit identity (user.name / user.email) is read by git itself from
402
432
  * local-then-global config — we pass nothing. Missing identity surfaces as
403
- * git's own descriptive commit error through the inherited stderr.
433
+ * git's own descriptive commit error via runGit's stderr handling.
404
434
  */
405
- export async function commitAndPushScaffoldAsync(appDir, jsonMode) {
435
+ export async function commitAndPushScaffoldAsync(appDir, jsonMode, pushClassify) {
406
436
  // Full add+commit+push sequence: any of the three commands may have been the
407
437
  // one that failed (e.g. `git commit` errors when user.name/user.email aren't
408
438
  // set), so re-running just `git push` wouldn't recover. The sequence is
@@ -411,17 +441,12 @@ export async function commitAndPushScaffoldAsync(appDir, jsonMode) {
411
441
  // `git push` still pushes the existing commit.
412
442
  const retryCommand = `git add . && git commit -m "Initial scaffold from 'ms app create'" && git push -u origin HEAD`;
413
443
  try {
414
- execFileSync('git', ['add', '.'], {
415
- cwd: appDir,
416
- stdio: ['ignore', 'ignore', 'inherit'],
417
- });
418
- execFileSync('git', ['commit', '-m', "Initial scaffold from 'ms app create'"], {
444
+ runGit(['add', '.'], { cwd: appDir });
445
+ runGit(['commit', '-m', "Initial scaffold from 'ms app create'"], { cwd: appDir });
446
+ runGit(['push', '-u', 'origin', 'HEAD'], {
419
447
  cwd: appDir,
420
- stdio: ['ignore', 'ignore', 'inherit'],
421
- });
422
- execFileSync('git', ['push', '-u', 'origin', 'HEAD'], {
423
- cwd: appDir,
424
- stdio: ['ignore', 'ignore', 'inherit'],
448
+ network: true,
449
+ classify: pushClassify,
425
450
  });
426
451
  return { success: true };
427
452
  }
@@ -435,18 +460,88 @@ export async function commitAndPushScaffoldAsync(appDir, jsonMode) {
435
460
  ? { ...err }
436
461
  : { message: String(err) };
437
462
  if (!jsonMode) {
463
+ // A classified native push failure (AuthenticationError / NetworkError)
464
+ // carries the actionable, cause-specific guidance — plus git's own output —
465
+ // in its message, but runGit's classify path throws *before* re-emitting
466
+ // stderr, so without this the human-readable terminal would only see the
467
+ // generic retry hint. (Unclassified add/commit failures and the live-stderr
468
+ // github push already surfaced git's output, so only re-print for the
469
+ // classified case to avoid duplicate noise.)
470
+ if (err instanceof AppError) {
471
+ printWarning(err.message);
472
+ }
438
473
  printWarning(`Could not commit and push the initial scaffold. Your app and local scaffold are ready. ` +
439
474
  `To finish wiring it up, run:\n ${retryCommand}`);
440
475
  }
441
476
  return { success: false, error: errorPayload, retryCommand };
442
477
  }
443
478
  }
479
+ const GIT_CLONE_ACCESS_FAILURE_SIGNATURES = [
480
+ 'repository not found',
481
+ 'fatal: repository',
482
+ 'could not read username',
483
+ 'terminal prompts disabled',
484
+ 'authentication failed',
485
+ 'permission denied',
486
+ '403',
487
+ 'remote: not found',
488
+ ];
489
+ function getCapturedStderr(err) {
490
+ const stderr = err.stderr;
491
+ if (typeof stderr === 'string') {
492
+ return stderr;
493
+ }
494
+ if (Buffer.isBuffer(stderr)) {
495
+ return stderr.toString();
496
+ }
497
+ if (stderr instanceof Uint8Array) {
498
+ return Buffer.from(stderr).toString();
499
+ }
500
+ return '';
501
+ }
502
+ function getUnderlyingCloneError(err, stderr) {
503
+ const message = err instanceof Error ? err.message : String(err);
504
+ const trimmedStderr = stderr.trim();
505
+ if (!trimmedStderr || message.includes(trimmedStderr)) {
506
+ return message;
507
+ }
508
+ return `${message}\n${trimmedStderr}`;
509
+ }
510
+ function isGitCloneAccessFailure(stderr) {
511
+ const normalizedStderr = stderr.toLowerCase();
512
+ return GIT_CLONE_ACCESS_FAILURE_SIGNATURES.some((signature) => normalizedStderr.includes(signature));
513
+ }
514
+ // Shared remediation body for the two access-failure surfaces (the pre-create
515
+ // probe and the post-create clone). Both are LOCAL `git clone` failures. The
516
+ // external-repo clone injects no credentials/config — it relies entirely on the
517
+ // user's own git setup — so the CLI can't meaningfully point at a specific
518
+ // credential mechanism (GCM, gh, SSH, PAT, …); the guidance stays generic.
519
+ // It also deliberately does NOT mention the Microsoft GitHub App or
520
+ // `git auth refresh`, which only affect the server-side Entra↔GitHub mapping.
521
+ // Carries no raw git output either; the raw detail is preserved on the thrown
522
+ // error's `errorData` for telemetry/debug — see the throw sites below.
523
+ function gitHubAccessFailureGuidance() {
524
+ return `Make sure the repository exists and that you have access to it.`;
525
+ }
526
+ function buildGitHubCloneAccessErrorMessage({ directory, repoUrl }) {
527
+ return `Failed to clone '${repoUrl}' into '${directory}'. ` + gitHubAccessFailureGuidance();
528
+ }
529
+ // Pre-create variant: the probe runs BEFORE the server app is created, so the
530
+ // lead makes explicit that nothing was created (no orphan / no recovery hint).
531
+ function buildGitHubAccessPreflightErrorMessage(repoUrl) {
532
+ return (`Cannot access repository '${repoUrl}', so no app was created. ` + gitHubAccessFailureGuidance());
533
+ }
534
+ function buildGenericCloneFailureMessage({ directory, repoUrl }) {
535
+ return `Failed to clone '${repoUrl}' into '${directory}'. See the git output above for details.`;
536
+ }
444
537
  /**
445
538
  * Clones the developer's external GitHub repo (`*.ghe.com` tenant or `github.com`) into `directory`.
446
539
  * Auth flows through the user's existing git credential setup — the CLI does not inject
447
540
  * tokens here. On failure, surfaces a CLI-friendly error pointing the user at the
448
- * GitHub App install prerequisite; git's own stderr is preserved (stdio: inherit) so
449
- * the underlying message appears inline above the wrapper.
541
+ * GitHub App install prerequisite. Git's stderr is captured (via `runGit`, with
542
+ * `emitStderr: false`) so it can be run through the access-failure classification,
543
+ * and is re-emitted on failure so the underlying message still appears inline
544
+ * above the wrapper.
450
545
  *
451
546
  * Targeting rules (mirrors `git clone` semantics):
452
547
  * - directory non-existent → clone creates it
@@ -456,82 +551,137 @@ export async function commitAndPushScaffoldAsync(appDir, jsonMode) {
456
551
  */
457
552
  export function cloneRepo({ directory, repoUrl }) {
458
553
  try {
459
- execFileSync('git', ['clone', repoUrl, directory], {
460
- stdio: ['ignore', 'inherit', 'inherit'],
461
- });
554
+ // Pipe + capture stderr (no `network`, so it is not inherited) so the catch
555
+ // can run it through `isGitCloneAccessFailure`; `emitStderr: false` lets the
556
+ // catch own the single re-emission below instead of runGit double-printing.
557
+ runGit(['clone', repoUrl, directory], { emitStderr: false });
462
558
  }
463
559
  catch (err) {
464
- throw new ConfigurationError(`Failed to clone '${repoUrl}' into '${directory}'. Ensure the Microsoft GitHub App is installed and authorized for this repository, and that your git credentials can access it (gh auth login, GCM, or your configured SSH key/PAT). Underlying error: ${err instanceof Error ? err.message : String(err)}`);
560
+ const stderr = getCapturedStderr(err);
561
+ if (stderr) {
562
+ // Re-emit git's own output live so the raw detail is still visible to the
563
+ // user (and in logs) without polluting the thrown error's message.
564
+ process.stderr.write(stderr);
565
+ }
566
+ const message = isGitCloneAccessFailure(stderr)
567
+ ? buildGitHubCloneAccessErrorMessage({ directory, repoUrl })
568
+ : buildGenericCloneFailureMessage({ directory, repoUrl });
569
+ // Keep the user-facing message clean; preserve the raw git detail on
570
+ // errorData for telemetry/debug rather than embedding it in the message.
571
+ // Clone failures span remote access and local git credential configuration.
572
+ throw new ConfigurationError(message, {
573
+ underlyingError: getUnderlyingCloneError(err, stderr),
574
+ });
465
575
  }
466
576
  }
467
577
  export function initLocalRepo({ directory, gitUrl, loginAuthority, authResource, jsonMode = false, }) {
468
- let gcmConfig;
578
+ const gcmConfig = buildGcmConfigOrThrow({ gitUrl, loginAuthority, authResource });
579
+ gitInit(directory);
580
+ writeGcmConfig(directory, gcmConfig);
581
+ addOrUpdateRemote(directory, gitUrl);
582
+ announceGitAuthentication(gitUrl, jsonMode);
583
+ fetchOrigin(directory, gitUrl);
584
+ checkoutMainIfPresent(directory);
585
+ }
586
+ /**
587
+ * Builds the per-host GCM OAuth config, re-wrapping any URL/protocol validation
588
+ * failure from {@link buildGcmConfig} as a `ConfigurationError` so the verb's
589
+ * error handling treats an invalid clone URL as a user-actionable setup problem.
590
+ */
591
+ function buildGcmConfigOrThrow({ gitUrl, loginAuthority, authResource, }) {
469
592
  try {
470
- gcmConfig = buildGcmConfig({ gitUrl, loginAuthority, authResource });
593
+ return buildGcmConfig({ gitUrl, loginAuthority, authResource });
471
594
  }
472
595
  catch (err) {
473
596
  throw new ConfigurationError(`Git credential helper setup failed: ${err instanceof Error ? err.message : String(err)}`);
474
597
  }
598
+ }
599
+ /** Creates the target directory (idempotent) and initializes an empty git repo. */
600
+ function gitInit(directory) {
475
601
  fs.mkdirSync(directory, { recursive: true });
476
- execFileSync('git', ['init'], { cwd: directory, stdio: ['ignore', 'ignore', 'inherit'] });
477
- // Write GCM config before fetching so the first network call is already authenticated.
602
+ runGit(['init'], { cwd: directory });
603
+ }
604
+ /** Writes GCM config before fetching so the first network call is already authenticated. */
605
+ function writeGcmConfig(directory, gcmConfig) {
478
606
  for (const [key, value] of gcmConfig) {
479
- execFileSync('git', ['config', '--local', key, value], {
480
- cwd: directory,
481
- stdio: ['ignore', 'ignore', 'inherit'],
482
- });
607
+ runGit(['config', '--local', key, value], { cwd: directory });
483
608
  }
484
- // `git remote add` fails if origin already exists. Pipe stderr so we can inspect the
485
- // message and only fall back to `set-url` for that specific case — other failures
486
- // (permissions, corrupt repo) must propagate rather than produce a misleading set-url error.
609
+ }
610
+ /**
611
+ * Adds `origin`, tolerating the already-exists case by falling back to
612
+ * `set-url`. `git remote add` fails if origin already exists. Capture stderr
613
+ * (allowFailure) so we can inspect the message and only fall back to `set-url`
614
+ * for that specific case — other failures (permissions, corrupt repo) must
615
+ * propagate rather than produce a misleading set-url error.
616
+ */
617
+ function addOrUpdateRemote(directory, gitUrl) {
618
+ const addRemote = runGit(['remote', 'add', 'origin', gitUrl], {
619
+ cwd: directory,
620
+ allowFailure: true,
621
+ emitStderr: false,
622
+ });
623
+ if (!addRemote.success) {
624
+ if (!addRemote.stderr.includes('already exists')) {
625
+ process.stderr.write(addRemote.stderr);
626
+ // `runGit` always populates `error` on the failure variant, so no fallback
627
+ // is needed (the GitResult union guarantees it here).
628
+ throw addRemote.error;
629
+ }
630
+ runGit(['remote', 'set-url', 'origin', gitUrl], { cwd: directory });
631
+ }
632
+ }
633
+ /**
634
+ * Best-effort host label for a git remote URL. Returns the URL's host, or — when
635
+ * the string isn't a parseable URL — the original string. Used for human-facing
636
+ * action labels (auth/fetch/push messages) where a malformed URL must not turn a
637
+ * cosmetic label into a thrown `Invalid URL` that masquerades as the real
638
+ * failure. The platform issues these URLs, so the fallback is effectively
639
+ * unreachable, but the guard keeps the label site from becoming a crash site.
640
+ */
641
+ function gitHostLabel(gitUrl) {
487
642
  try {
488
- execFileSync('git', ['remote', 'add', 'origin', gitUrl], {
489
- cwd: directory,
490
- stdio: ['ignore', 'ignore', 'pipe'],
491
- });
643
+ return new URL(gitUrl).host;
492
644
  }
493
- catch (err) {
494
- const stderr = err.stderr?.toString() ?? '';
495
- if (!stderr.includes('already exists')) {
496
- process.stderr.write(stderr);
497
- throw err;
498
- }
499
- execFileSync('git', ['remote', 'set-url', 'origin', gitUrl], {
500
- cwd: directory,
501
- stdio: ['ignore', 'ignore', 'inherit'],
502
- });
645
+ catch {
646
+ return gitUrl;
503
647
  }
504
- // The first `git fetch` triggers GCM's OAuth flow if the user has no cached
505
- // credentials, which can pop a browser without warning. Tell the user up
506
- // front so they don't see the browser as a surprise (and know which host /
507
- // account to authenticate against). Skipped under `--json` so the prose
508
- // doesn't end up interleaved with the JSON envelope on stdout.
648
+ }
649
+ /**
650
+ * Prints the pre-fetch authentication notice. The first `git fetch` triggers
651
+ * GCM's OAuth flow if the user has no cached credentials, which can pop a
652
+ * browser without warning. Tell the user up front so they don't see the browser
653
+ * as a surprise (and know which host / account to authenticate against). Skipped
654
+ * under `--json` so the prose doesn't end up interleaved with the JSON envelope
655
+ * on stdout.
656
+ */
657
+ function announceGitAuthentication(gitUrl, jsonMode) {
509
658
  if (!jsonMode) {
510
- printInfo(`Authenticating to ${new URL(gitUrl).host} for the initial git fetch. ` +
659
+ printInfo(`Authenticating to ${gitHostLabel(gitUrl)} for the initial git fetch. ` +
511
660
  'A browser window may open. Sign in with the account associated with this tenant.');
512
661
  }
513
- execFileSync('git', ['fetch', 'origin'], {
662
+ }
663
+ /** Fetches `origin`, classifying any auth/network failure against the remote host. */
664
+ function fetchOrigin(directory, gitUrl) {
665
+ runGit(['fetch', 'origin'], {
514
666
  cwd: directory,
515
- stdio: ['ignore', 'ignore', 'inherit'],
667
+ network: true,
668
+ classify: { action: `fetching from ${gitHostLabel(gitUrl)}` },
516
669
  });
517
- // Probe for origin/main locally (no network needed after fetch).
518
- // A broad catch here is safe: the only failure mode is "ref doesn't exist".
519
- let hasMain = false;
520
- try {
521
- execFileSync('git', ['rev-parse', '--verify', 'origin/main'], {
522
- cwd: directory,
523
- stdio: ['ignore', 'ignore', 'ignore'],
524
- });
525
- hasMain = true;
526
- }
527
- catch {
528
- // Empty GRS repo — origin/main not yet present.
529
- }
530
- if (hasMain) {
531
- execFileSync('git', ['checkout', '-b', 'main', 'origin/main'], {
532
- cwd: directory,
533
- stdio: ['ignore', 'ignore', 'inherit'],
534
- });
670
+ }
671
+ /**
672
+ * Checks out `main` tracking `origin/main` when that ref exists. Probe for
673
+ * origin/main locally (no network needed after fetch). A failed probe means the
674
+ * ref doesn't exist (empty GRS repo); allowFailure turns that expected case into
675
+ * a `success: false` signal instead of a throw.
676
+ */
677
+ function checkoutMainIfPresent(directory) {
678
+ const mainProbe = runGit(['rev-parse', '--verify', 'origin/main'], {
679
+ cwd: directory,
680
+ allowFailure: true,
681
+ emitStderr: false,
682
+ });
683
+ if (mainProbe.success) {
684
+ runGit(['checkout', '-b', 'main', 'origin/main'], { cwd: directory });
535
685
  }
536
686
  }
537
687
  // ─── Recovery hint for post-create failures ─────────────────────────────────
@@ -543,108 +693,21 @@ export function initLocalRepo({ directory, gitUrl, loginAuthority, authResource,
543
693
  export function buildOrphanedAppRecoveryHint(serverAppId, environmentId) {
544
694
  return (`The server app is still in environment ${environmentId} (id: ${serverAppId}).\n` +
545
695
  `If you want to clean it up before retrying, run the following command:\n` +
546
- ` npx ${CLI_BINARY_NAME} app delete --app ${serverAppId} --environment-id ${environmentId} --force`);
696
+ ` ${CLI_BINARY_NAME} app delete --app ${serverAppId} --environment-id ${environmentId} --force`);
547
697
  }
548
698
  // ─── Preflight helpers ──────────────────────────────────────────────────────
549
699
  // All checks below run before any server call so a local misconfiguration
550
- // (missing git, bad template source, unwritable directory) fails fast and
551
- // never leaves an orphaned MAAF app + GRS repo on the server.
700
+ // (bad template source, unwritable directory, …) fails fast and never leaves
701
+ // an orphaned MAAF app + GRS repo on the server. The git presence + version
702
+ // check lives in `Utils/GitVersionChecker.ts`.
552
703
  // ────────────────────────────────────────────────────────────────────────────
553
- export function ensureGitInstalled() {
554
- try {
555
- execFileSync('git', ['--version'], { stdio: ['ignore', 'ignore', 'ignore'] });
556
- }
557
- catch {
558
- throw new ConfigurationError("'git' is required but was not found on PATH. Install git from https://git-scm.com/ and retry.");
559
- }
560
- }
561
- // Known giget provider prefixes. Used only for a *soft* "did you mean?" warning —
562
- // not a hard reject, so adding a new giget provider doesn't silently fail
563
- // preflight here. Sourced from giget's docs (github, gitlab, bitbucket, sourcehut,
564
- // http, https) as of the version pinned in package.json. Update opportunistically
565
- // when bumping giget; a stale entry only suppresses one warning, never blocks
566
- // a valid template.
567
- const KNOWN_GIGET_PROVIDERS = new Set([
568
- 'github',
569
- 'gitlab',
570
- 'bitbucket',
571
- 'sourcehut',
572
- 'http',
573
- 'https',
574
- ]);
575
- // Single-edit Damerau-Levenshtein distance, capped at 1: returns true iff `a`
576
- // is one substitution, one insertion, one deletion, or one adjacent
577
- // transposition away from `b`. Used to pick the closest known provider for a
578
- // "did you mean?" hint without pulling in a dependency.
579
- function isOneEditAway(a, b) {
580
- if (a === b)
581
- return false;
582
- if (Math.abs(a.length - b.length) > 1)
583
- return false;
584
- if (a.length === b.length) {
585
- let diff = 0;
586
- let transposition = null;
587
- for (let i = 0; i < a.length; i++) {
588
- if (a[i] !== b[i]) {
589
- diff++;
590
- if (transposition === null)
591
- transposition = i;
592
- if (diff > 2)
593
- return false;
594
- }
595
- }
596
- if (diff <= 1)
597
- return true;
598
- if (diff === 2 && transposition !== null) {
599
- // Adjacent swap: gihub vs github, swap positions transposition and transposition+1.
600
- return a[transposition] === b[transposition + 1] && a[transposition + 1] === b[transposition];
601
- }
602
- return false;
603
- }
604
- // One insertion or deletion away. Walk both strings; allow one skip.
605
- const [shorter, longer] = a.length < b.length ? [a, b] : [b, a];
606
- let i = 0;
607
- let j = 0;
608
- let skipped = false;
609
- while (i < shorter.length && j < longer.length) {
610
- if (shorter[i] === longer[j]) {
611
- i++;
612
- j++;
613
- continue;
614
- }
615
- if (skipped)
616
- return false;
617
- skipped = true;
618
- j++;
619
- }
620
- return true;
621
- }
622
704
  export function ensureTemplateSourceValid(source) {
623
- // Hard validation: only enforce non-empty. Any further structural validation
624
- // (provider name, URL shape, registry lookup) is giget's responsibility —
625
- // duplicating its provider list locally goes stale silently when giget adds
626
- // support for a new provider, turning a valid template into a misleading
627
- // "unknown provider" error at preflight.
628
- if (!source) {
629
- throw new UsageError('Template source is empty. Use --template to specify one.');
630
- }
631
- // Soft validation: if the source looks like `<prefix>:<rest>` but the prefix
632
- // is one edit away from a known giget provider (e.g. `gihub:` → `github:`),
633
- // warn but proceed. giget will produce the authoritative error if the
634
- // prefix is genuinely invalid; for the common typo case we surface the fix
635
- // before the network round-trip. No hard-fail keeps this forward-compatible
636
- // with new giget providers — a stale `KNOWN_GIGET_PROVIDERS` set only
637
- // suppresses a warning, it never blocks a valid template.
638
- const prefixMatch = /^([a-z][a-z0-9+.-]*):/i.exec(source);
639
- if (!prefixMatch)
640
- return;
641
- const prefix = prefixMatch[1].toLowerCase();
642
- if (KNOWN_GIGET_PROVIDERS.has(prefix))
643
- return;
644
- const suggestion = [...KNOWN_GIGET_PROVIDERS].find((known) => isOneEditAway(prefix, known));
645
- if (suggestion) {
646
- printWarning(`Template source '${source}' uses an unrecognized provider prefix '${prefix}:'. Did you mean '${suggestion}:'?`);
647
- }
705
+ // Delegates to the github-only parser in DownloadTemplate. The parser throws
706
+ // `UsageError` for any unsupported shape (non-`github:` prefix, malformed
707
+ // URL, missing owner/repo) which is what preflight wants: surface the
708
+ // problem before any server call. We discard the parsed result; the real
709
+ // download path re-parses at call time.
710
+ parseGithubTemplate(source);
648
711
  }
649
712
  export function ensureTargetDirectoryEmpty(appDir) {
650
713
  // Scaffolding into a non-empty directory would mix the template with the
@@ -670,7 +733,7 @@ export function ensureRepoTargetDirectoryEmpty(appDir) {
670
733
  return;
671
734
  const entries = fs.readdirSync(appDir);
672
735
  if (entries.length > 0) {
673
- throw new UsageError(`'${appDir}' is not empty. --repo requires an empty target directory; specify an empty directory as the first argument or move to one before running.`);
736
+ throw new UsageError(`'${appDir}' is not empty. --repo requires an empty target directory; specify an empty directory as the first argument, move to one before running, or run 'ms app init' to bind an existing project to a new app.`);
674
737
  }
675
738
  }
676
739
  /**
@@ -682,14 +745,18 @@ export function ensureRepoTargetDirectoryEmpty(appDir) {
682
745
  * bandwidth on the rejection path is tens of KB even for a multi-GB repo. The
683
746
  * temp dir is removed in `finally`.
684
747
  *
685
- * Failure semantics — fail-OPEN:
748
+ * Failure semantics:
686
749
  * - Non-empty tree at HEAD → throws `UsageError` with the "is not empty" /
687
750
  * "ms app init" actionable lead, BEFORE createAsync. No orphan, no leak.
688
751
  * - Empty tree (incl. the cleaned-up-repo edge case: commit1 add, commit2
689
752
  * delete all) → ls-tree returns no lines → returns without throwing.
690
- * - Any error from git itself (auth, network blip, server doesn't support
691
- * --filter, repo doesn't exist, etc.) swallow and return. The subsequent
692
- * cloneRepo will surface a friendlier message for the user-facing failure;
753
+ * - Clone fails with a recognizable access/auth signature (private repo
754
+ * hidden as "not found", missing local credentials, 403, …) throws
755
+ * `ConfigurationError` BEFORE createAsync so we never create a server-side
756
+ * app the user can't clone (fail FAST, no orphan).
757
+ * - Any other git error (network blip, server doesn't support --filter,
758
+ * temp-fs hiccup, …) → swallow and return (fail OPEN). The subsequent
759
+ * cloneRepo surfaces the friendlier message for the user-facing failure;
693
760
  * we don't want this probe to become a parallel error-message surface.
694
761
  */
695
762
  export function ensureExternalRepoIsEmpty(repoUrl) {
@@ -703,27 +770,38 @@ export function ensureExternalRepoIsEmpty(repoUrl) {
703
770
  return;
704
771
  }
705
772
  try {
706
- try {
707
- execFileSync('git', ['clone', '--depth=1', '--filter=blob:none', '--no-checkout', repoUrl, probeDir], { stdio: ['ignore', 'ignore', 'ignore'] });
708
- }
709
- catch {
710
- // Clone itself failed (auth, network, repo not found, server doesn't
711
- // support partial clone, etc.). Fail open: the user-facing error will
712
- // come from the later cloneRepo which has the friendlier messaging.
773
+ const probeClone = runGit(['clone', '--depth=1', '--filter=blob:none', '--no-checkout', repoUrl, probeDir], { allowFailure: true, emitStderr: false });
774
+ if (!probeClone.success) {
775
+ // Clone failed. When git reports a recognizable access/auth failure
776
+ // (a private repo hidden as "not found", missing local credentials, a
777
+ // 403, etc.) fail FAST here before createAsync so we never create a
778
+ // server-side app the user can't clone (no orphan). For ambiguous or
779
+ // transient errors (server doesn't support --filter, network blip, a
780
+ // temp-fs hiccup, …) stay fail-OPEN and let the later cloneRepo surface
781
+ // the friendlier, fuller message.
782
+ const stderr = probeClone.stderr;
783
+ if (isGitCloneAccessFailure(stderr)) {
784
+ // Clean user-facing message; raw git detail kept on errorData for
785
+ // telemetry/debug. The probe clone is silent (stderr piped, not
786
+ // inherited), so we don't echo the confusing internal probe command.
787
+ throw new ConfigurationError(buildGitHubAccessPreflightErrorMessage(repoUrl), {
788
+ underlyingError: getUnderlyingCloneError(probeClone.error, stderr),
789
+ });
790
+ }
713
791
  return;
714
792
  }
715
- let lsTreeOutput;
716
- try {
717
- lsTreeOutput = execFileSync('git', ['-C', probeDir, 'ls-tree', '-r', 'HEAD'], {
718
- stdio: ['ignore', 'pipe', 'ignore'],
719
- }).toString();
720
- }
721
- catch {
793
+ const lsTree = runGit(['-C', probeDir, 'ls-tree', '-r', 'HEAD'], {
794
+ captureStdout: true,
795
+ allowFailure: true,
796
+ emitStderr: false,
797
+ });
798
+ if (!lsTree.success) {
722
799
  // Truly empty repo (no commits, no HEAD): ls-tree errors. Treat as empty
723
800
  // so create proceeds; cloneRepo of a no-commits remote produces an empty
724
801
  // working tree, which is the happy path.
725
802
  return;
726
803
  }
804
+ const lsTreeOutput = lsTree.stdout;
727
805
  if (lsTreeOutput.trim().length > 0) {
728
806
  // `ls-tree -r HEAD` enumerates every tracked path — including hidden
729
807
  // ones (.gitignore, .gitattributes, etc.). Any tracked file at all