uniweb 1.0.0 → 1.0.2

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 (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
@@ -0,0 +1,113 @@
1
+ /**
2
+ * GitHub fetcher — downloads a template from a GitHub repository
3
+ * (`github:owner/repo[#ref]`).
4
+ *
5
+ * ⭐ Without a token it downloads from GitHub's archive host —
6
+ * `codeload.github.com/<owner>/<repo>/tar.gz/<ref>`, `HEAD` naming the default
7
+ * branch — which is not the REST API, so the API's anonymous limit (60 requests an
8
+ * hour per address) does not apply. With GITHUB_TOKEN set it uses the API's
9
+ * tarball endpoint, which a private repository needs and which then counts
10
+ * against the token's own, higher limit. ⛔ Until 2026-10-08 every download went
11
+ * through the API, token or not.
12
+ */
13
+
14
+ import { pipeline } from 'node:stream/promises'
15
+ import { createGunzip } from 'node:zlib'
16
+ import { mkdtemp, rm } from 'node:fs/promises'
17
+ import { tmpdir } from 'node:os'
18
+ import { join } from 'node:path'
19
+ import * as tar from 'tar'
20
+ import { fetchWithRetry as sharedFetchWithRetry } from '../../utils/fetch-retry.js'
21
+
22
+ /**
23
+ * Where to download a repository's tarball from, and with which headers.
24
+ *
25
+ * @param {string} owner
26
+ * @param {string} repo
27
+ * @param {string} [ref='HEAD'] - Branch, tag, or commit
28
+ * @param {string|null} [token] - GITHUB_TOKEN, when set
29
+ * @returns {{ url: string, headers: Record<string, string> }}
30
+ */
31
+ export function githubTarballRequest(owner, repo, ref = 'HEAD', token = null) {
32
+ if (token) {
33
+ return {
34
+ url: `https://api.github.com/repos/${owner}/${repo}/tarball/${ref}`,
35
+ headers: {
36
+ Accept: 'application/vnd.github+json',
37
+ 'User-Agent': 'uniweb-cli',
38
+ Authorization: `Bearer ${token}`
39
+ }
40
+ }
41
+ }
42
+ return {
43
+ url: `https://codeload.github.com/${owner}/${repo}/tar.gz/${ref}`,
44
+ headers: { 'User-Agent': 'uniweb-cli' }
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Fetch a template from a GitHub repository
50
+ *
51
+ * @param {string} owner - Repository owner
52
+ * @param {string} repo - Repository name
53
+ * @param {Object} options - Fetch options
54
+ * @param {string} options.ref - Branch, tag, or commit (default: HEAD)
55
+ * @param {Function} options.onProgress - Progress callback
56
+ * @returns {Promise<Object>} { tempDir, ref }
57
+ */
58
+ export async function fetchGitHubTemplate(owner, repo, options = {}) {
59
+ const { ref = 'HEAD', onProgress } = options
60
+
61
+ const displayRef = ref === 'HEAD' ? 'latest' : ref
62
+ onProgress?.(`Fetching ${owner}/${repo}@${displayRef} from GitHub...`)
63
+
64
+ // GitHub provides tarballs without requiring git
65
+ const token = process.env.GITHUB_TOKEN || null
66
+ const { url, headers } = githubTarballRequest(owner, repo, ref, token)
67
+
68
+ const tempDir = await mkdtemp(join(tmpdir(), 'uniweb-template-'))
69
+
70
+ try {
71
+ const response = await fetchWithRetry(url, { headers })
72
+
73
+ if (!response.ok) {
74
+ if (response.status === 404) {
75
+ const at = ref === 'HEAD' ? '' : ` at ${ref}`
76
+ throw new Error(
77
+ `Repository not found: ${owner}/${repo}${at}` +
78
+ (token ? '' : ' — a private repository needs GITHUB_TOKEN')
79
+ )
80
+ }
81
+ if (response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') {
82
+ throw new Error('GitHub API rate limit exceeded for this GITHUB_TOKEN.')
83
+ }
84
+ throw new Error(`GitHub download failed: HTTP ${response.status}`)
85
+ }
86
+
87
+ onProgress?.(`Downloading and extracting...`)
88
+
89
+ await pipeline(
90
+ response.body,
91
+ createGunzip(),
92
+ tar.extract({ cwd: tempDir, strip: 1 }) // the archive's one top folder: 'repo-ref/' (codeload) or 'owner-repo-sha/' (API)
93
+ )
94
+
95
+ onProgress?.(`Extracted to ${tempDir}`)
96
+
97
+ return {
98
+ tempDir,
99
+ ref: displayRef
100
+ }
101
+ } catch (err) {
102
+ // Clean up on error
103
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {})
104
+ throw err
105
+ }
106
+ }
107
+
108
+ // Retry + timeout live in one place now (`../../utils/fetch-retry.js`).
109
+ // This file carried its own copy, as did the other two fetchers — three
110
+ // byte-identical implementations, and none reachable from the upload paths
111
+ // that had no retry at all. The wrapper keeps this caller's own timeout.
112
+ const fetchWithRetry = (url, options = {}, maxRetries = 3) =>
113
+ sharedFetchWithRetry(url, { ...options }, { retries: maxRetries, timeoutMs: 60000 })
@@ -0,0 +1,85 @@
1
+ /**
2
+ * npm package fetcher - downloads and extracts templates from npm registry
3
+ */
4
+
5
+ import { pipeline } from 'node:stream/promises'
6
+ import { createGunzip } from 'node:zlib'
7
+ import { mkdtemp, rm } from 'node:fs/promises'
8
+ import { tmpdir } from 'node:os'
9
+ import { join } from 'node:path'
10
+ import * as tar from 'tar'
11
+ import { fetchWithRetry as sharedFetchWithRetry } from '../../utils/fetch-retry.js'
12
+
13
+ /**
14
+ * Fetch a template from npm registry
15
+ *
16
+ * @param {string} packageName - npm package name (e.g., '@uniweb/template-marketing')
17
+ * @param {Object} options - Fetch options
18
+ * @param {Function} options.onProgress - Progress callback
19
+ * @returns {Promise<Object>} { tempDir, version, metadata }
20
+ */
21
+ export async function fetchNpmTemplate(packageName, options = {}) {
22
+ const { onProgress } = options
23
+
24
+ onProgress?.(`Fetching package info for ${packageName}...`)
25
+
26
+ // Query npm registry for package metadata
27
+ const registryUrl = `https://registry.npmjs.org/${encodeURIComponent(packageName).replace('%40', '@')}`
28
+
29
+ const metaResponse = await fetchWithRetry(registryUrl)
30
+ if (!metaResponse.ok) {
31
+ if (metaResponse.status === 404) {
32
+ throw new Error(`Package not found: ${packageName}`)
33
+ }
34
+ throw new Error(`npm registry error: ${metaResponse.status}`)
35
+ }
36
+
37
+ const pkgMeta = await metaResponse.json()
38
+ const version = pkgMeta['dist-tags']?.latest
39
+ if (!version) {
40
+ throw new Error(`No published versions found for ${packageName}`)
41
+ }
42
+
43
+ const versionMeta = pkgMeta.versions[version]
44
+ const tarballUrl = versionMeta?.dist?.tarball
45
+ if (!tarballUrl) {
46
+ throw new Error(`No tarball URL found for ${packageName}@${version}`)
47
+ }
48
+
49
+ onProgress?.(`Downloading ${packageName}@${version}...`)
50
+
51
+ // Download and extract tarball
52
+ const tempDir = await mkdtemp(join(tmpdir(), 'uniweb-template-'))
53
+
54
+ try {
55
+ const tarballResponse = await fetchWithRetry(tarballUrl)
56
+ if (!tarballResponse.ok) {
57
+ throw new Error(`Failed to download tarball: ${tarballResponse.status}`)
58
+ }
59
+
60
+ await pipeline(
61
+ tarballResponse.body,
62
+ createGunzip(),
63
+ tar.extract({ cwd: tempDir, strip: 1 }) // strip 'package/' prefix
64
+ )
65
+
66
+ onProgress?.(`Extracted to ${tempDir}`)
67
+
68
+ return {
69
+ tempDir,
70
+ version,
71
+ metadata: versionMeta
72
+ }
73
+ } catch (err) {
74
+ // Clean up on error
75
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {})
76
+ throw err
77
+ }
78
+ }
79
+
80
+ // Retry + timeout live in one place now (`../../utils/fetch-retry.js`).
81
+ // This file carried its own copy, as did the other two fetchers — three
82
+ // byte-identical implementations, and none reachable from the upload paths
83
+ // that had no retry at all. The wrapper keeps this caller's own timeout.
84
+ const fetchWithRetry = (url, options = {}, maxRetries = 3) =>
85
+ sharedFetchWithRetry(url, { ...options }, { retries: maxRetries, timeoutMs: 30000 })
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Release fetcher — downloads an official template from the release of the
3
+ * templates repo this CLI was published with.
4
+ *
5
+ * ⭐ ONE REQUEST, AND NOT TO GITHUB'S API. The release is pinned
6
+ * (templates/resolver.js, OFFICIAL_TEMPLATES_RELEASE) and a release's files sit
7
+ * at fixed URLs, so the template's tarball is downloaded from its release URL
8
+ * directly. The name was checked against this CLI's own roster before anything
9
+ * is fetched, and a tarball the release lacks answers 404.
10
+ *
11
+ * ⛔ Until 2026-10-08 a `create` first asked the REST API for the release, to find
12
+ * its manifest.json, then downloaded the manifest to check the name, then the
13
+ * tarball: three requests, the first counted against the API's anonymous limit of
14
+ * 60 an hour per address — which a shared network or a CI runner can spend, and
15
+ * `create` then failed with "rate limit exceeded".
16
+ */
17
+
18
+ import { pipeline } from 'node:stream/promises'
19
+ import { createGunzip } from 'node:zlib'
20
+ import { mkdtemp, rm } from 'node:fs/promises'
21
+ import { tmpdir } from 'node:os'
22
+ import { join } from 'node:path'
23
+ import * as tar from 'tar'
24
+ import { fetchWithRetry as sharedFetchWithRetry } from '../../utils/fetch-retry.js'
25
+
26
+ // GitHub repository for official templates
27
+ const TEMPLATES_REPO = 'uniweb/templates'
28
+
29
+ /**
30
+ * The URL a release serves one template's tarball at.
31
+ *
32
+ * @param {string} name - Template name (e.g., 'marketing')
33
+ * @param {string} version - The release tag, e.g. `v0.18.6`
34
+ */
35
+ export function officialTemplateUrl(name, version) {
36
+ return `https://github.com/${TEMPLATES_REPO}/releases/download/${version}/${name}.tar.gz`
37
+ }
38
+
39
+ /**
40
+ * Fetch a template from one release of the official templates
41
+ *
42
+ * @param {string} name - Template name (e.g., 'marketing')
43
+ * @param {Object} options - Fetch options
44
+ * @param {string} options.version - The release tag, e.g. `v0.18.6`
45
+ * @param {Function} options.onProgress - Progress callback
46
+ * @returns {Promise<Object>} { tempDir, baseTempDir, version }
47
+ */
48
+ export async function fetchOfficialTemplate(name, options = {}) {
49
+ const { version, onProgress } = options
50
+ if (!version) throw new Error('fetchOfficialTemplate needs the release tag to download from')
51
+
52
+ onProgress?.(`Downloading ${name} template (${version})...`)
53
+
54
+ const response = await fetchWithRetry(officialTemplateUrl(name, version), {
55
+ headers: { 'User-Agent': 'uniweb-cli' }
56
+ })
57
+
58
+ if (!response.ok) {
59
+ if (response.status === 404) {
60
+ throw new Error(`Release ${version} of ${TEMPLATES_REPO} has no template "${name}".`)
61
+ }
62
+ throw new Error(`Could not download the ${name} template (HTTP ${response.status}).`)
63
+ }
64
+
65
+ // Extract to temp directory
66
+ const tempDir = await mkdtemp(join(tmpdir(), 'uniweb-template-'))
67
+
68
+ try {
69
+ onProgress?.('Extracting template...')
70
+
71
+ await pipeline(response.body, createGunzip(), tar.extract({ cwd: tempDir, strip: 0 }))
72
+
73
+ // The tarball holds the template in a folder named after it:
74
+ // marketing.tar.gz extracts to marketing/template.json
75
+ return {
76
+ tempDir: join(tempDir, name),
77
+ baseTempDir: tempDir, // For cleanup
78
+ version
79
+ }
80
+ } catch (err) {
81
+ // Clean up on error
82
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {})
83
+ throw err
84
+ }
85
+ }
86
+
87
+ // Retry + timeout live in one place now (`../../utils/fetch-retry.js`).
88
+ // This file carried its own copy, as did the other two fetchers — three
89
+ // byte-identical implementations, and none reachable from the upload paths
90
+ // that had no retry at all. The wrapper keeps this caller's own timeout.
91
+ const fetchWithRetry = (url, options = {}, maxRetries = 3) =>
92
+ sharedFetchWithRetry(url, { redirect: 'follow', ...options }, { retries: maxRetries, timeoutMs: 60000 })
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Template resolution and application for the CLI
3
+ */
4
+
5
+ import { existsSync } from 'node:fs'
6
+ import { readFile, rm } from 'node:fs/promises'
7
+ import { join, resolve } from 'node:path'
8
+ import { homedir } from 'node:os'
9
+
10
+ import {
11
+ parseTemplateId,
12
+ getTemplateDisplayName,
13
+ BUILTIN_TEMPLATES,
14
+ OFFICIAL_TEMPLATES,
15
+ OFFICIAL_TEMPLATES_RELEASE,
16
+ buildTemplateChoices,
17
+ templatePickerPageSize
18
+ } from './resolver.js'
19
+ import { fetchNpmTemplate } from './fetchers/npm.js'
20
+ import { fetchGitHubTemplate } from './fetchers/github.js'
21
+ import { fetchOfficialTemplate } from './fetchers/release.js'
22
+
23
+ /**
24
+ * Resolve a template identifier and return the template path
25
+ *
26
+ * @param {string} identifier - Template identifier
27
+ * @param {Object} options - Resolution options
28
+ * @param {Function} options.onProgress - Progress callback
29
+ * @returns {Promise<Object>} { type, path, metadata, cleanup }
30
+ */
31
+ export async function resolveTemplate(identifier, options = {}) {
32
+ const { onProgress } = options
33
+ const parsed = parseTemplateId(identifier)
34
+
35
+ switch (parsed.type) {
36
+ case 'builtin':
37
+ return {
38
+ type: 'builtin',
39
+ name: parsed.name
40
+ // Built-in templates are handled programmatically by the CLI
41
+ // No path or cleanup needed
42
+ }
43
+
44
+ case 'official':
45
+ return resolveOfficialTemplate(parsed.name, options)
46
+
47
+ case 'npm':
48
+ return resolveNpmTemplate(parsed.package, options)
49
+
50
+ case 'github':
51
+ return resolveGitHubTemplate(parsed, options)
52
+
53
+ case 'local':
54
+ return resolveLocalTemplate(parsed.path, options)
55
+
56
+ default:
57
+ throw new Error(`Unknown template type: ${parsed.type}`)
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Resolve an official template from the release this CLI was published with —
63
+ * never `latest`, so its content matches the package versions the scaffold pins
64
+ * (resolver.js, OFFICIAL_TEMPLATES_RELEASE).
65
+ */
66
+ async function resolveOfficialTemplate(name, options = {}) {
67
+ const { onProgress } = options
68
+
69
+ if (!OFFICIAL_TEMPLATES_RELEASE) {
70
+ throw new Error(
71
+ "This CLI's framework index names no @uniweb/templates release to download from."
72
+ )
73
+ }
74
+ const { tempDir, baseTempDir, version } = await fetchOfficialTemplate(name, {
75
+ version: OFFICIAL_TEMPLATES_RELEASE,
76
+ onProgress
77
+ })
78
+
79
+ onProgress?.(`Using official template: ${name} (${version})`)
80
+
81
+ return {
82
+ type: 'official',
83
+ name,
84
+ version,
85
+ path: tempDir,
86
+ cleanup: async () => {
87
+ // Clean up the base temp directory (parent of the template)
88
+ await rm(baseTempDir, { recursive: true, force: true }).catch(() => {})
89
+ }
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Resolve a template from npm
95
+ */
96
+ async function resolveNpmTemplate(packageName, options = {}) {
97
+ const { onProgress } = options
98
+
99
+ onProgress?.(`Resolving npm template: ${packageName}`)
100
+
101
+ const { tempDir, version, metadata } = await fetchNpmTemplate(packageName, {
102
+ onProgress
103
+ })
104
+
105
+ return {
106
+ type: 'npm',
107
+ package: packageName,
108
+ version,
109
+ path: tempDir,
110
+ cleanup: async () => {
111
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {})
112
+ }
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Resolve a template from GitHub
118
+ */
119
+ async function resolveGitHubTemplate(parsed, options = {}) {
120
+ const { onProgress } = options
121
+ const { owner, repo, ref } = parsed
122
+
123
+ onProgress?.(`Resolving GitHub template: ${owner}/${repo}`)
124
+
125
+ const { tempDir } = await fetchGitHubTemplate(owner, repo, {
126
+ ref,
127
+ onProgress
128
+ })
129
+
130
+ return {
131
+ type: 'github',
132
+ owner,
133
+ repo,
134
+ ref,
135
+ path: tempDir,
136
+ cleanup: async () => {
137
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {})
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Resolve a local template from a filesystem path
144
+ */
145
+ async function resolveLocalTemplate(templatePath, options = {}) {
146
+ const { onProgress } = options
147
+
148
+ // Expand ~ to home directory
149
+ let resolvedPath = templatePath.startsWith('~')
150
+ ? templatePath.replace(/^~/, homedir())
151
+ : templatePath
152
+
153
+ // Resolve to absolute path
154
+ resolvedPath = resolve(resolvedPath)
155
+
156
+ if (!existsSync(resolvedPath)) {
157
+ throw new Error(`Local template not found: ${resolvedPath}`)
158
+ }
159
+
160
+ onProgress?.(`Using local template: ${resolvedPath}`)
161
+
162
+ // Read template.json for name
163
+ const metaPath = join(resolvedPath, 'template.json')
164
+ let name = templatePath
165
+ if (existsSync(metaPath)) {
166
+ try {
167
+ const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
168
+ name = meta.name || templatePath
169
+ } catch {
170
+ // Use path as name if template.json can't be parsed
171
+ }
172
+ }
173
+
174
+ return {
175
+ type: 'local',
176
+ name,
177
+ path: resolvedPath,
178
+ cleanup: null
179
+ }
180
+ }
181
+
182
+ // Re-export for convenience
183
+ export {
184
+ parseTemplateId,
185
+ getTemplateDisplayName,
186
+ BUILTIN_TEMPLATES,
187
+ OFFICIAL_TEMPLATES,
188
+ buildTemplateChoices,
189
+ templatePickerPageSize
190
+ }