Skip to content

Commit b7d6a72

Browse files
cli: preflight git check and network retries in openui create (#1223)
* cli: add create preflight and network retries Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * cli: drop unit tests from create preflight/retry change Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * cli: move retry telemetry reporter to base Telemetry Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * cli: rename Telemetry.retryReporter to reportRetry Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * cli: extract retry default constants Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * cli: split withRetry consumers into fn and options Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * cli: rename templateSourceError to throwTemplateSourceError Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * cli: inline trackNetworkRetry into reportRetry Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * cli: rename reportRetry to reportNetworkRetry Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * cli: withRetry(label, fn, options?) with network-only default Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * cli: simplify fetch failure message Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * cli: reuse throwCommandFailure for dependency install errors Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Zahle Khan <zahle@thesys.dev> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 8aee0bd commit b7d6a72

14 files changed

Lines changed: 301 additions & 118 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@openuidev/cli": patch
3+
---
4+
5+
Add a preflight git check with per-OS install hints, retry network failures with backoff during source checkout, catalog fetch, and dependency installation, and record retry attempts in failure telemetry alongside a new `cli_network_retry` event.

‎packages/openui-cli/src/commands/create/lib/create-example.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export async function runCreateExample(params: {
5757
targetDir,
5858
name,
5959
packageManager: packageManager.name,
60+
onRetry: tel.reportNetworkRetry("source_checkout"),
6061
});
6162
layout = verbose ? await runScaffold() : await withSpinner("Scaffolding...", runScaffold);
6263
if (!verbose) {

‎packages/openui-cli/src/commands/create/lib/examples-catalog.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { styleText } from "node:util";
22

33
import { fetchSourceFile } from "../../../lib/checkout";
44
import { CreateError } from "../../../lib/errors";
5+
import type { RetryAttemptInfo } from "../../../lib/retry";
56

67
export const EXAMPLES_CATALOG_PATH = "examples/examples.json";
78

@@ -65,8 +66,12 @@ function parseExamplesCatalog(raw: string): ExampleProject[] {
6566
}
6667

6768
/** Prefetch the examples catalog from GitHub. */
68-
export async function loadExamplesCatalog(): Promise<ExampleProject[]> {
69-
const { content } = await fetchSourceFile(EXAMPLES_CATALOG_PATH);
69+
export async function loadExamplesCatalog(
70+
opts: {
71+
onRetry?: (info: RetryAttemptInfo) => void;
72+
} = {},
73+
): Promise<ExampleProject[]> {
74+
const { content } = await fetchSourceFile(EXAMPLES_CATALOG_PATH, { onRetry: opts.onRetry });
7075
return parseExamplesCatalog(content);
7176
}
7277

‎packages/openui-cli/src/commands/create/lib/install.ts‎

Lines changed: 34 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import * as path from "node:path";
33

44
import { printLogTail, QUIET_COMMAND_CAPTURE_LIMIT } from "../../../lib/command-output";
55
import type { PackageManager } from "../../../lib/detect-package-manager";
6-
import { CliCancelledError, CreateError, processErrorProperties } from "../../../lib/errors";
6+
import { CliCancelledError, cliErrorProperties, throwCommandFailure } from "../../../lib/errors";
77
import { mutedNpmEnv, runCommand } from "../../../lib/process-runner";
8+
import { withRetry } from "../../../lib/retry";
89
import { withSpinner } from "../../../lib/spinner";
910
import type { OverlayName, TemplateName } from "./create-types";
1011
import type { CreateTelemetryClient } from "./telemetry";
@@ -82,53 +83,47 @@ export async function installProjectDependencies(params: {
8283
if (verbose) {
8384
console.info(`Installing dependencies with: ${installCmd}\n`);
8485
}
85-
const installResult = verbose
86-
? await runInstall()
87-
: await withSpinner("Installing dependencies...", runInstall);
88-
if (!installResult.error && installResult.status === 0) {
86+
const attemptInstall = async () => {
87+
const result = await runInstall();
88+
if (!result.error && result.status === 0) return result;
89+
8990
if (!verbose) {
90-
console.info("✓ Dependencies installed");
91+
printLogTail(result.diagnosticTail, "install log (tail)");
9192
}
92-
tel.trackDependencyInstallSucceeded({
93-
template,
94-
ai_setup: aiSetup,
95-
dependency_installed: true,
93+
throwCommandFailure(result, "dependency_install", "dependency install failed", {
94+
error_class: "dependency",
95+
error_code: "NONZERO_EXIT",
9696
});
97-
return true;
98-
}
97+
};
9998

100-
if (!verbose) {
101-
printLogTail(installResult.diagnosticTail, "install log (tail)");
102-
}
103-
const properties = processErrorProperties(installResult, "dependency_install", {
104-
error_class: "dependency",
105-
error_code: "NONZERO_EXIT",
106-
});
107-
if (properties.error_class === "user_cancelled") {
108-
tel.trackDependencyInstallCancelled({
99+
try {
100+
const runWithRetry = () =>
101+
withRetry("Dependency install", attemptInstall, {
102+
onRetry: tel.reportNetworkRetry("dependency_install"),
103+
});
104+
await (verbose ? runWithRetry() : withSpinner("Installing dependencies...", runWithRetry));
105+
} catch (err) {
106+
const properties = {
109107
template,
110108
ai_setup: aiSetup,
111109
dependency_installed: false,
112-
...properties,
113-
});
114-
throw new CliCancelledError(
115-
"dependency_install",
116-
properties.cancellation_exit_code ?? 0,
117-
properties,
118-
);
110+
...cliErrorProperties(err),
111+
};
112+
if (err instanceof CliCancelledError) {
113+
tel.trackDependencyInstallCancelled(properties);
114+
} else {
115+
tel.trackDependencyInstallFailed(properties);
116+
}
117+
throw err;
118+
}
119+
120+
if (!verbose) {
121+
console.info("✓ Dependencies installed");
119122
}
120-
tel.trackDependencyInstallFailed({
123+
tel.trackDependencyInstallSucceeded({
121124
template,
122125
ai_setup: aiSetup,
123-
dependency_installed: false,
124-
...properties,
126+
dependency_installed: true,
125127
});
126-
const { failure_stage, error_class, error_code, ...metadata } = properties;
127-
throw new CreateError(
128-
failure_stage,
129-
"dependency install failed",
130-
error_class,
131-
error_code,
132-
metadata,
133-
);
128+
return true;
134129
}

‎packages/openui-cli/src/commands/create/lib/resolve.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { CliCancelledError, CreateError } from "../../../lib/errors";
22
import { resolveArgs } from "../../../lib/resolve-args";
3+
import type { RetryAttemptInfo } from "../../../lib/retry";
34
import type { OverlayName, TemplateName } from "./create-types";
45
import {
56
findExample,
@@ -40,8 +41,9 @@ export async function loadCreateCatalog(params: {
4041
template?: TemplateName;
4142
backendFramework?: OverlayName;
4243
interactive: boolean;
44+
onRetry?: (info: RetryAttemptInfo) => void;
4345
}): Promise<CreateCatalog> {
44-
const { example, template: requestedTemplate, backendFramework, interactive } = params;
46+
const { example, template: requestedTemplate, backendFramework, interactive, onRetry } = params;
4547

4648
rejectConflictingScaffoldSelectors({
4749
example,
@@ -61,13 +63,16 @@ export async function loadCreateCatalog(params: {
6163
}
6264

6365
if (example) {
64-
const examples = await loadExamplesCatalog();
66+
const examples = await loadExamplesCatalog({ onRetry });
6567
findExample(example, examples);
6668
return { examples };
6769
}
6870

6971
if (interactive) {
70-
const [catalog, examples] = await Promise.all([loadTemplatesCatalog(), loadExamplesCatalog()]);
72+
const [catalog, examples] = await Promise.all([
73+
loadTemplatesCatalog({ onRetry }),
74+
loadExamplesCatalog({ onRetry }),
75+
]);
7176
const template = requestedTemplate ?? DEFAULT_TEMPLATE_KEY;
7277
const templateEntry = findCatalogTemplate(catalog, template);
7378
if (backendFramework) {
@@ -76,7 +81,7 @@ export async function loadCreateCatalog(params: {
7681
return { examples, template, templateEntry };
7782
}
7883

79-
const catalog = await loadTemplatesCatalog();
84+
const catalog = await loadTemplatesCatalog({ onRetry });
8085
const template = requestedTemplate ?? DEFAULT_TEMPLATE_KEY;
8186
const templateEntry = findCatalogTemplate(catalog, template);
8287
if (backendFramework) {

‎packages/openui-cli/src/commands/create/lib/scaffold-example.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as path from "node:path";
55
import { checkoutSource } from "../../../lib/checkout";
66
import type { PackageManagerName } from "../../../lib/detect-package-manager";
77
import { CreateError } from "../../../lib/errors";
8+
import type { RetryAttemptInfo } from "../../../lib/retry";
89
import type { ExampleProject } from "./examples-catalog";
910

1011
const ARTIFACT_DIRS = new Set(["node_modules", ".next", ".turbo", "dist", ".nuxt", ".svelte-kit"]);
@@ -159,11 +160,12 @@ export async function scaffoldExample(params: {
159160
targetDir: string;
160161
name: string;
161162
packageManager: PackageManagerName;
163+
onRetry?: (info: RetryAttemptInfo) => void;
162164
}): Promise<ExampleLayout> {
163-
const { example, targetDir, name, packageManager } = params;
165+
const { example, targetDir, name, packageManager, onRetry } = params;
164166

165167
try {
166-
await checkoutSource(example.path, { dest: targetDir });
168+
await checkoutSource(example.path, { dest: targetDir, onRetry });
167169
} catch (err) {
168170
if (err instanceof CreateError) throw err;
169171
throw new CreateError(

‎packages/openui-cli/src/commands/create/lib/templates-catalog.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { fetchSourceFile } from "../../../lib/checkout";
22
import { CreateError } from "../../../lib/errors";
3+
import type { RetryAttemptInfo } from "../../../lib/retry";
34

45
export const TEMPLATES_CATALOG_PATH = "templates/templates.json";
56

@@ -62,8 +63,12 @@ function parseTemplatesCatalog(raw: string): CatalogTemplate[] {
6263
}
6364

6465
/** Prefetch the template catalog from GitHub. */
65-
export async function loadTemplatesCatalog(): Promise<CatalogTemplate[]> {
66-
const { content } = await fetchSourceFile(TEMPLATES_CATALOG_PATH);
66+
export async function loadTemplatesCatalog(
67+
opts: {
68+
onRetry?: (info: RetryAttemptInfo) => void;
69+
} = {},
70+
): Promise<CatalogTemplate[]> {
71+
const { content } = await fetchSourceFile(TEMPLATES_CATALOG_PATH, { onRetry: opts.onRetry });
6772
return parseTemplatesCatalog(content);
6873
}
6974

‎packages/openui-cli/src/commands/create/run.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import * as fs from "node:fs";
22

3+
import { localSourceDir } from "../../lib/checkout";
34
import type { CliContext } from "../../lib/context";
45
import { resolveInstallPackageManager } from "../../lib/detect-package-manager";
56
import { cliErrorProperties, CreateError, processErrorProperties } from "../../lib/errors";
7+
import { ensureGitAvailable } from "../../lib/git-preflight";
68
import { applyScaffoldFiles, resolveTemplateSource } from "../../lib/scaffold";
79
import { withSpinner } from "../../lib/spinner";
810
import { runCreateExample } from "./lib/create-example";
@@ -46,11 +48,14 @@ export async function runCreateApp(options: CreateAppOptions, ctx: CliContext):
4648
immediate_arg: options.immediate,
4749
});
4850

51+
if (!localSourceDir()) await ensureGitAvailable();
52+
const sourceRetryReporter = tel.reportNetworkRetry("source_checkout");
4953
const catalog = await loadCreateCatalog({
5054
example: options.example,
5155
template: options.template,
5256
backendFramework: options.backendFramework,
5357
interactive,
58+
onRetry: sourceRetryReporter,
5459
});
5560
const { name, targetDir } = await resolveProjectIdentity(options.name, interactive, tel);
5661

@@ -123,7 +128,9 @@ export async function runCreateApp(options: CreateAppOptions, ctx: CliContext):
123128

124129
let overlay: TemplateOverlay | undefined;
125130
const runScaffold = async () => {
126-
const { dir: templateDir } = await resolveTemplateSource(template);
131+
const { dir: templateDir } = await resolveTemplateSource(template, {
132+
onRetry: sourceRetryReporter,
133+
});
127134
tel.trackScaffoldStarted({ template, ai_setup: aiSetup });
128135
try {
129136
overlay = applyScaffoldFiles({

0 commit comments

Comments
 (0)