Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .chronus/changes/init-template-source-2026-7-14-21-30-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: internal
packages:
- "@typespec/compiler"
---

Refactor `tsp init` template loading around a URI-based `TemplateSource` abstraction. A `UriTemplateSource` handles local and remote templates (paths and URLs), while built-in ("core") templates are addressed through an `internal:` scheme that resolves to an injectable provider. This lets alternative hosts (e.g. an offline single-executable compiler) serve bundled templates via an `InMemoryTemplateSource` without coupling template loading to the `CompilerHost` filesystem.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: internal
packages:
- "typespec-vscode"
---

Reuse the compiler's `UriTemplateSource.loadIndex()` to load `tsp init` template indexes instead of the extension's own file/URL reading and JSON parsing, unifying how core and configured templates are loaded.
12 changes: 3 additions & 9 deletions packages/compiler/src/init/core-templates.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { CompilerPackageRoot } from "../core/node-host.js";
import { resolvePath } from "../core/path-utils.js";
import type { SystemHost } from "../core/types.js";
import { defaultInternalTemplateSource } from "./template-source/index.js";

export const templatesDir = resolvePath(CompilerPackageRoot, "templates");
export interface LoadedCoreTemplates {
readonly baseUri: string;
readonly templates: Record<string, any>;
Expand All @@ -11,12 +9,8 @@ export interface LoadedCoreTemplates {
let typeSpecCoreTemplates: LoadedCoreTemplates | undefined;
export async function getTypeSpecCoreTemplates(host: SystemHost): Promise<LoadedCoreTemplates> {
if (typeSpecCoreTemplates === undefined) {
const file = await host.readFile(resolvePath(templatesDir, "scaffolding.json"));
const content = JSON.parse(file.text);
typeSpecCoreTemplates = {
baseUri: templatesDir,
templates: content,
};
const { templates, baseUri } = await defaultInternalTemplateSource(host).loadIndex();
typeSpecCoreTemplates = { baseUri, templates };
}
return typeSpecCoreTemplates;
}
116 changes: 56 additions & 60 deletions packages/compiler/src/init/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,20 @@ import * as semver from "semver";
import { CliCompilerHost } from "../core/cli/types.js";
import { parseCliArgsArgOption } from "../core/cli/utils.js";
import { createDiagnostic } from "../core/messages.js";
import { getBaseFileName, getDirectoryPath } from "../core/path-utils.js";
import { CompilerHost, Diagnostic, NoTarget, SourceFile } from "../core/types.js";
import { getBaseFileName } from "../core/path-utils.js";
import { Diagnostic, NoTarget } from "../core/types.js";
import { installTypeSpecDependencies } from "../install/install.js";
import { MANIFEST } from "../manifest.js";
import { readUrlOrPath } from "../utils/misc.js";
import { getTypeSpecCoreTemplates } from "./core-templates.js";
import { validateTemplateDefinitions, ValidationResult } from "./init-template-validate.js";
import { EmitterTemplate, InitTemplate, InitTemplateInput } from "./init-template.js";
import { checkbox } from "./prompts.js";
import { isFileSkipGeneration, makeScaffoldingConfig, scaffoldNewProject } from "./scaffold.js";
import {
defaultInternalTemplateSource,
UriTemplateSource,
type LoadedTemplateIndex,
type TemplateSource,
} from "./template-source/index.js";

export interface InitTypeSpecProjectOptions {
readonly templatesUrl?: string;
Expand All @@ -23,6 +27,12 @@ export interface InitTypeSpecProjectOptions {
readonly args?: string[];
readonly "project-name"?: string;
readonly emitters?: string[];
/**
* Provider that the `internal:` scheme resolves to (the built-in templates). Defaults to the
* compiler's on-disk `templates/` directory; the standalone single-executable injects an in-memory
* bundle instead.
*/
readonly internalTemplateSource?: TemplateSource;
}

export async function initTypeSpecProject(
Expand Down Expand Up @@ -55,45 +65,54 @@ export async function initTypeSpecProjectWorker(

const folderName = getBaseFileName(directory);

// Download template configuration and prompt user to select a template
// No validation is done until one has been selected
const typeSpecCoreTemplates = await getTypeSpecCoreTemplates(host);
const result =
options.templatesUrl === undefined
? (typeSpecCoreTemplates as LoadedTemplate)
: await downloadTemplates(host, options.templatesUrl, skipPrompts);
const isRemote = options.templatesUrl !== undefined;
// A `templatesUrl` points at a filesystem path or URL; otherwise use the built-in ("internal")
// templates, either an injected provider (e.g. bundled in the standalone executable) or the
// compiler's on-disk `templates/` directory.
const source: TemplateSource =
options.templatesUrl !== undefined
? new UriTemplateSource(host, options.templatesUrl)
: (options.internalTemplateSource ?? defaultInternalTemplateSource(host));

if (isRemote) {
warning(
`Downloading or using an untrusted template may contain malicious packages that can compromise your system and data. Proceed with caution and verify the source.`,
);
if (!skipPrompts && !(await confirm("Continue"))) {
process.exit(1);
}
}

// No validation is done until a template has been selected.
const index = await loadTemplateIndex(source, options.templatesUrl);
if (skipPrompts && !options.template) {
// A template has to be defined if we're skipping prompts
throw new Error(
`A template must be specified when --no-prompt is used. Specify one of the following templates via --template: ${Object.keys(
result.templates,
index.templates,
)
.map((t) => `"${t}"`)
.join(", ")}`,
);
}
const templateName = options.template ?? (await promptTemplateSelection(result.templates));
const templateName = options.template ?? (await promptTemplateSelection(index.templates));

if (!result.templates[templateName]) {
if (!index.templates[templateName]) {
throw new Error(`Unexpected error: Cannot find template ${templateName}`);
}

// Validate minimum compiler version for non built-in templates
if (
!skipPrompts &&
result !== typeSpecCoreTemplates &&
!(await validateTemplate(result.templates[templateName], result))
) {
if (!skipPrompts && isRemote && !(await validateTemplate(index.templates[templateName], index))) {
return;
}

const template = result.templates[templateName] as InitTemplate;
const template = index.templates[templateName] as InitTemplate;
const name = await resolveProjectName(folderName, options);

const emitters = await selectEmitters(template, options);
const parameters = await promptCustomParameters(template, options);
const scaffoldingConfig = makeScaffoldingConfig(template, {
baseUri: result.baseUri,
source,
name,
directory,
parameters,
Expand Down Expand Up @@ -246,49 +265,26 @@ async function confirm(message: string): Promise<boolean> {
});
}

export interface LoadedTemplate {
readonly baseUri: string;
readonly templates: Record<string, InitTemplate>;
readonly file: SourceFile;
}
async function downloadTemplates(
host: CompilerHost,
url: string,
skipCheck: boolean,
): Promise<LoadedTemplate> {
warning(
`Downloading or using an untrusted template may contain malicious packages that can compromise your system and data. Proceed with caution and verify the source.`,
);
if (!skipCheck && !(await confirm("Continue"))) {
process.exit(1);
}
let file: SourceFile;
async function loadTemplateIndex(
source: TemplateSource,
url: string | undefined,
): Promise<LoadedTemplateIndex> {
try {
file = await readUrlOrPath(host, url);
} catch (e: any) {
throw new InitTemplateError([
createDiagnostic({
code: "init-template-download-failed",
target: NoTarget,
format: { url: url, message: e.message },
}),
]);
}

let json: unknown;
try {
json = JSON.parse(file.text);
return await source.loadIndex();
} catch (e: any) {
if (url === undefined) {
throw e;
}
const code =
e instanceof SyntaxError ? "init-template-invalid-json" : "init-template-download-failed";
throw new InitTemplateError([
createDiagnostic({
code: "init-template-invalid-json",
code,
target: NoTarget,
format: { url: url, message: e.message },
format: { url, message: e.message },
}),
]);
}

return { templates: json as any, baseUri: getDirectoryPath(file.path), file };
}

function getTemplateName(template: InitTemplate) {
Expand Down Expand Up @@ -331,17 +327,17 @@ function isTemplateCompatibleWithTspVersion(template: InitTemplate): boolean {
);
}

async function validateTemplate(template: any, loaded: LoadedTemplate): Promise<boolean> {
async function validateTemplate(template: any, index: LoadedTemplateIndex): Promise<boolean> {
// After selection, validate the template definition
const currentCompilerVersion = MANIFEST.version;
let validationResult: ValidationResult;
// 1. If current version > compilerVersion, proceed with strict validation
if (isTemplateCompatibleWithTspVersion(template)) {
validationResult = validateTemplateDefinitions(template, loaded.file, true);
validationResult = validateTemplateDefinitions(template, index.indexFile, true);

// 1.1 If strict validation fails, try relaxed validation
if (!validationResult.valid) {
validationResult = validateTemplateDefinitions(template, loaded.file, false);
validationResult = validateTemplateDefinitions(template, index.indexFile, false);
}
} else {
// 2. if version mis-match or none specified, warn and prompt user to continue or not
Expand All @@ -352,7 +348,7 @@ async function validateTemplate(template: any, loaded: LoadedTemplate): Promise<
)
) {
// 2.1 If user choose to continue, proceed with relaxed validation
validationResult = validateTemplateDefinitions(template, loaded.file, false);
validationResult = validateTemplateDefinitions(template, index.indexFile, false);
} else {
return false;
}
Expand Down
16 changes: 10 additions & 6 deletions packages/compiler/src/init/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { getDirectoryPath, joinPaths } from "../core/path-utils.js";
import type { SystemHost } from "../core/types.js";
import { fetchLatestPackageManifest } from "../package-manger/npm-registry.js";
import type { PackageJson } from "../types/package-json.js";
import { readUrlOrPath, resolveRelativeUrlOrPath } from "../utils/misc.js";
import {
createFileTemplatingContext,
type FileTemplatingContext,
Expand All @@ -16,6 +15,7 @@ import type {
InitTemplateLibrary,
InitTemplateLibrarySpec,
} from "./init-template.js";
import type { TemplateSource } from "./template-source/index.js";

export const TypeSpecConfigFilename = "tspconfig.yaml";

Expand All @@ -24,9 +24,10 @@ export interface ScaffoldingConfig {
template: InitTemplate;

/**
* Path where this template was loaded from.
* Source the template was loaded from. Used to read the template's files during scaffolding.
* Optional: templates that declare no `files` never need it.
*/
baseUri: string;
source?: TemplateSource;

/**
* Directory full path where the project should be initialized.
Expand Down Expand Up @@ -73,7 +74,6 @@ export function makeScaffoldingConfig(
return {
template,
libraries: config.libraries ?? template.libraries?.map(normalizeLibrary) ?? [],
baseUri: config.baseUri ?? ".",
name: config.name ?? "",
directory: config.directory ?? "",
parameters: config.parameters ?? {},
Expand Down Expand Up @@ -248,8 +248,12 @@ async function writeFile(
context: FileTemplatingContext,
file: InitTemplateFile,
) {
const baseDir = config.baseUri + "/";
const template = await readUrlOrPath(host, resolveRelativeUrlOrPath(baseDir, file.path));
if (config.source === undefined) {
throw new Error(
`Cannot resolve template file "${file.path}": template was loaded without a source.`,
);
}
const template = await config.source.readFile(file.path);
const content = render(template.text, context);
const destinationFilePath = joinPaths(config.directory, file.destination);
// create folders in case they don't exist
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { CompilerPackageRoot } from "../../core/node-host.js";
import { resolvePath } from "../../core/path-utils.js";
import type { SystemHost } from "../../core/types.js";
import type { TemplateSource } from "./types.js";
import { UriTemplateSource } from "./uri-template-source.js";

/**
* The default source of built-in ("internal") templates: the compiler package's on-disk
* `templates/` directory, read through the given {@link SystemHost}. The standalone single-executable
* overrides this by injecting its own {@link InMemoryTemplateSource} bundle.
*/
export function defaultInternalTemplateSource(host: SystemHost): TemplateSource {
return UriTemplateSource.fromDirectory(host, resolvePath(CompilerPackageRoot, "templates"));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { normalizePath } from "../../core/path-utils.js";
import { createSourceFile } from "../../core/source-file.js";
import type { SourceFile } from "../../core/types.js";
import type { LoadedTemplateIndex, TemplateSource } from "./types.js";
import { SCAFFOLDING_FILENAME } from "./uri-template-source.js";

/** Prefix used to label the virtual files this source serves in diagnostics. */
const INTERNAL_URI_PREFIX = "internal:";

/**
* A {@link TemplateSource} serving an index and its template files from an in-memory map keyed by
* relative path. Used for built-in templates that are bundled rather than on disk — e.g. the
* standalone single-executable injects one built from its embedded assets. Keys and lookups are
* normalized so callers can use `./`-relative or backslash paths interchangeably.
*/
export class InMemoryTemplateSource implements TemplateSource {
#files: ReadonlyMap<string, string>;
#indexPath: string;

/**
* @param files Template files keyed by their relative path (including the index).
* @param indexPath Relative path of the index within {@link files}.
*/
constructor(files: ReadonlyMap<string, string>, indexPath: string = SCAFFOLDING_FILENAME) {
this.#indexPath = indexPath;
this.#files = new Map([...files].map(([key, value]) => [normalizeKey(key), value]));
}

async loadIndex(): Promise<LoadedTemplateIndex> {
const indexFile = this.#read(this.#indexPath);
const templates = JSON.parse(indexFile.text);
return { templates, indexFile, baseUri: INTERNAL_URI_PREFIX };
}

async readFile(relativePath: string): Promise<SourceFile> {
return this.#read(relativePath);
}

#read(relativePath: string): SourceFile {
const key = normalizeKey(relativePath);
const content = this.#files.get(key);
if (content === undefined) {
const error: NodeJS.ErrnoException = new Error(
`ENOENT: bundled template file not found, '${relativePath}'`,
);
error.code = "ENOENT";
throw error;
}
return createSourceFile(content, `${INTERNAL_URI_PREFIX}${key}`);
}
}

function normalizeKey(path: string): string {
return normalizePath(path).replace(/^\/+/, "");
}
4 changes: 4 additions & 0 deletions packages/compiler/src/init/template-source/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { defaultInternalTemplateSource } from "./default-core-templates.js";
export { InMemoryTemplateSource } from "./in-memory-template-source.js";
export type { LoadedTemplateIndex, TemplateSource } from "./types.js";
export { SCAFFOLDING_FILENAME, UriTemplateSource } from "./uri-template-source.js";
22 changes: 22 additions & 0 deletions packages/compiler/src/init/template-source/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { SourceFile } from "../../core/types.js";
import type { InitTemplate } from "../init-template.js";

/** Result of loading a template index (the set of templates offered by a {@link TemplateSource}). */
export interface LoadedTemplateIndex {
/** Templates keyed by their id. */
readonly templates: Record<string, InitTemplate>;
/** Source file the index was parsed from. Used to position validation diagnostics. */
readonly indexFile: SourceFile;
/** Location the index was loaded from (a directory path or URL). Informational only. */
readonly baseUri: string;
}

/**
* A source of `tsp init` templates. Decouples template loading from the host filesystem: each context
* supplies its own implementation — {@link UriTemplateSource} for a path or URL, or
* {@link InMemoryTemplateSource} for a bundle addressed through the `internal:` scheme.
*/
export interface TemplateSource {
loadIndex(): Promise<LoadedTemplateIndex>;
readFile(relativePath: string): Promise<SourceFile>;
}
Loading
Loading