@netlify/agent-runner-cli 1.119.11-alpha.0 → 1.119.12-alpha.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.
@@ -1,3 +1,3 @@
1
- import{spawnSync as S}from"child_process";import{readdirSync as w,statSync as I,writeFileSync as m,mkdirSync as N}from"fs";import{createRequire as v}from"module";import s from"path";import t from"process";var u=v(import.meta.url);function x(){let e=u("@netlify/ts-cli/package.json"),n=s.dirname(u.resolve("@netlify/ts-cli/package.json"));return s.join(n,e.bin)}function M(e){let n=e.slice(2),r=n[0];(!r||r.startsWith("--"))&&(console.error("Usage: scaffold.js <template-id> [--package-manager npm|pnpm|yarn]"),t.exit(1));let i=n.indexOf("--package-manager"),o=i!==-1?n[i+1]??"":"";return{templateId:r,packageManager:o}}var b=new Set(["node_modules",".git",".netlify",".claude",".next","dist","build",".cache"]);function D(e,n=4){let r=[];function i(o,l){if(l>n)return;let g;try{g=w(o).sort()}catch{return}for(let f of g){if(b.has(f))continue;let a=s.join(o,f),p=s.relative(e,a),d=!1;try{d=I(a).isDirectory()}catch{continue}d?(r.push(p+"/"),i(a,l+1)):r.push(p)}}return i(e,0),r.join(`
2
- `)}var{templateId:h,packageManager:c}=M(t.argv),O=x(),R=t.env.NVM_BIN?`${t.env.NVM_BIN}/node`:"node",k=[O,"--target-dir","./","--no-git","--json","--add-ons",h];c&&k.push("--package-manager",c);var y=S(R,k,{stdio:"inherit"});y.status!==0&&t.exit(y.status??1);var j={template:h,packageManager:c};try{N(s.join(t.cwd(),".netlify"),{recursive:!0}),m(s.join(t.cwd(),".netlify","scaffold-result.json"),JSON.stringify(j));let e=D(t.cwd());m(s.join(t.cwd(),".netlify","scaffold-tree.md"),e)}catch{}console.log(JSON.stringify(j));
1
+ import{spawnSync as w}from"child_process";import{existsSync as N,readdirSync as j,statSync as x,writeFileSync as k,mkdirSync as _}from"fs";import{createRequire as v}from"module";import n from"path";import t from"process";var A=".axis-scaffold-skip-install",h=v(import.meta.url);function L(){let e=h("@netlify/ts-cli/package.json"),s=n.dirname(h.resolve("@netlify/ts-cli/package.json"));return n.join(s,e.bin)}function O(e){let s=e.slice(2),r=s[0];(!r||r.startsWith("--"))&&(console.error("Usage: scaffold.js <template-id> [--package-manager npm|pnpm|yarn]"),t.exit(1));let o=s.indexOf("--package-manager"),a=o!==-1?s[o+1]??"":"";return{templateId:r,packageManager:a}}var T=new Set(["node_modules",".git",".netlify",".claude",".next","dist","build",".cache"]);function F(e,s=4){let r=[];function o(a,g){if(g>s)return;let m;try{m=j(a).sort()}catch{return}for(let p of m){if(T.has(p))continue;let c=n.join(a,p),y=n.relative(e,c),S=!1;try{S=x(c).isDirectory()}catch{continue}S?(r.push(y+"/"),o(c,g+1)):r.push(y)}}return o(e,0),r.join(`
2
+ `)}var{templateId:d,packageManager:l}=O(t.argv),C=L(),D=t.env.NVM_BIN?`${t.env.NVM_BIN}/node`:"node",u=[C,"--target-dir","./","--no-git","--json","--add-ons",d];l&&u.push("--package-manager",l);var I=t.env.AXIS_WORKSPACE,E=I!==void 0&&N(n.join(I,A));(t.env.NETLIFY_SCAFFOLD_NO_INSTALL==="1"||E)&&u.push("--no-install");var i=w(D,u,{stdio:["inherit","pipe","inherit"],encoding:"utf8"});i.status!==0&&(i.stdout&&t.stdout.write(i.stdout),t.exit(i.status??1));var f;try{f=JSON.parse(i.stdout)?.starter?.framework}catch{i.stdout&&t.stdout.write(i.stdout)}var M={template:d,packageManager:l};try{_(n.join(t.cwd(),".netlify"),{recursive:!0}),k(n.join(t.cwd(),".netlify","scaffold-result.json"),JSON.stringify(M));let e=F(t.cwd());k(n.join(t.cwd(),".netlify","scaffold-tree.md"),e)}catch{}var R=f?` (${f})`:"";console.log(`\u2713 Scaffolded "${d}" template${R}. File tree: .netlify/scaffold-tree.md`);
3
3
  //# sourceMappingURL=scaffold.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@netlify/agent-runner-cli",
3
3
  "type": "module",
4
- "version": "1.119.11-alpha.0",
4
+ "version": "1.119.12-alpha.0",
5
5
  "description": "CLI tool for running Netlify agents",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -65,6 +65,7 @@
65
65
  "@commitlint/config-conventional": "^20.0.0",
66
66
  "@eslint/compat": "^2.0.0",
67
67
  "@eslint/js": "^9.35.0",
68
+ "@netlify/axis": "^1.11.0",
68
69
  "@netlify/eslint-config-node": "^7.0.1",
69
70
  "@types/node": "^24.5.0",
70
71
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -74,6 +75,7 @@
74
75
  "eslint-config-prettier": "^10.1.8",
75
76
  "eslint-plugin-n": "^17.0.0",
76
77
  "husky": "^9.0.0",
78
+ "jiti": "^2.7.0",
77
79
  "patch-package": "^8.0.0",
78
80
  "tsup": "^8.5.0",
79
81
  "typescript": "^5.0.0",
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Installs the orchestrator's bundled skills into the agent's skill directory
4
+ * by calling `setupAgentSkills` from `src/context.js` — the same function the
5
+ * orchestrator itself uses at runtime. Intended to run as an axis scenario
6
+ * setup action when the scenario wants to A/B with-skills vs without.
7
+ *
8
+ * Uses `jiti` to import the orchestrator source directly so we don't need a
9
+ * pre-built `dist/` (context.js transitively imports several `.ts` modules
10
+ * that plain Node ESM can't resolve).
11
+ *
12
+ * Usage:
13
+ * node install-skills.mjs [--agent claude-code|codex|gemini] [--target-dir <abs>]
14
+ *
15
+ * Omit `--agent` to install for all known agents in one pass.
16
+ */
17
+ import process from 'node:process'
18
+ import { parseArgs } from 'node:util'
19
+
20
+ import { createJiti } from 'jiti'
21
+
22
+ const jiti = createJiti(import.meta.url, { interopDefault: true })
23
+ const { setupAgentSkills, resetSkillsCache } = await jiti.import('../../src/context.js')
24
+
25
+ // Axis adapter name → orchestrator runner key (matches `AGENT_SKILLS_DIRS` in src/context.js).
26
+ const RUNNER_BY_AGENT = {
27
+ 'claude-code': 'claude',
28
+ codex: 'codex',
29
+ gemini: 'gemini',
30
+ }
31
+
32
+ const ALL_AGENTS = Object.keys(RUNNER_BY_AGENT)
33
+
34
+ const main = async () => {
35
+ const { values } = parseArgs({
36
+ args: process.argv.slice(2),
37
+ options: {
38
+ agent: { type: 'string' },
39
+ 'target-dir': { type: 'string' },
40
+ },
41
+ strict: false,
42
+ })
43
+
44
+ const agents = values.agent ? [values.agent] : ALL_AGENTS
45
+
46
+ for (const agent of agents) {
47
+ const runner = RUNNER_BY_AGENT[agent] ?? agent
48
+ resetSkillsCache()
49
+ const installed = await setupAgentSkills(runner, values['target-dir'] ? { targetDir: values['target-dir'] } : {})
50
+ process.stderr.write(`[install-skills] ${runner}: ${installed.length} skill(s)\n`)
51
+ }
52
+ }
53
+
54
+ main().catch((err) => {
55
+ process.stderr.write(`[install-skills] error: ${err.message ?? err}\n`)
56
+ process.exit(1)
57
+ })
@@ -0,0 +1,311 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Shared teardown script for axis scenarios.
4
+ *
5
+ * Looks up (or creates) a Netlify site by name, zips the workspace source, and
6
+ * POSTs the zip to `/sites/{site_id}/builds` so Netlify builds and deploys it
7
+ * server-side. Writes a small markdown block to `$AXIS_OUTPUT` so the deploy
8
+ * URL is rendered in the axis report.
9
+ *
10
+ * Quiet by design: prints nothing on success aside from the JSON summary on
11
+ * stdout. Errors include the raw argv to make argument-passing problems
12
+ * obvious without leaving routine runs noisy.
13
+ *
14
+ * Usage:
15
+ * node netlify-deploy.mjs --name axis-<slug> [--dir .] [--account <id-or-slug>] [--message "…"] [--branch axis-preview]
16
+ *
17
+ * Requires: NETLIFY_AUTH_TOKEN env var, and the `zip` binary on PATH.
18
+ */
19
+ import { execFile } from 'node:child_process'
20
+ import { appendFile, mkdtemp, readFile, stat } from 'node:fs/promises'
21
+ import { tmpdir } from 'node:os'
22
+ import { join } from 'node:path'
23
+ import process from 'node:process'
24
+ import { parseArgs, promisify } from 'node:util'
25
+
26
+ const execFileAsync = promisify(execFile)
27
+
28
+ const NETLIFY_API = 'https://api.netlify.com/api/v1'
29
+
30
+ const IGNORE_PATTERNS = [
31
+ 'node_modules*',
32
+ '.git*',
33
+ '.netlify*',
34
+ '.next*',
35
+ 'dist*',
36
+ 'build*',
37
+ '.nuxt*',
38
+ '.output*',
39
+ '.vercel*',
40
+ '__pycache__*',
41
+ '.venv*',
42
+ '.env',
43
+ '.DS_Store',
44
+ 'Thumbs.db',
45
+ '*.log',
46
+ '.nyc_output*',
47
+ 'coverage*',
48
+ '.cache*',
49
+ '.tmp*',
50
+ '.temp*',
51
+ ]
52
+
53
+ const SITE_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
54
+
55
+ const rawArgv = process.argv.slice(2)
56
+
57
+ /**
58
+ * DNS subdomain labels max out at 63 characters. Netlify deploy preview URLs
59
+ * are `https://<branch>--<site>.netlify.app`, so `branch + '--' + site` has
60
+ * to fit within that limit. We leave one byte of margin (62) so we don't
61
+ * trip any stricter Netlify-side checks.
62
+ */
63
+ const SUBDOMAIN_MAX = 62
64
+ /** Random suffix chars to keep each axis run unique without overwriting previous deploys. */
65
+ const HEX_LEN = 4
66
+
67
+ const slugifyBranch = (value) =>
68
+ String(value)
69
+ .toLowerCase()
70
+ .replace(/[^a-z0-9]+/g, '-')
71
+ .replace(/^-+|-+$/g, '')
72
+
73
+ /**
74
+ * Default branch from `AXIS_AGENT`/`AXIS_VARIANT` so each axis combination
75
+ * gets its own preview URL. Branch length is computed against the site name
76
+ * so the full `<branch>--<site>` subdomain stays within
77
+ * {@link SUBDOMAIN_MAX} characters. When the agent+variant identifier is
78
+ * too long to fit, it is truncated from the right (variant first, then
79
+ * agent), keeping enough room for the hex prefix on every run.
80
+ */
81
+ const envDefaultBranch = (siteName = '') => {
82
+ const { AXIS_AGENT, AXIS_VARIANT } = process.env
83
+ const parts = [AXIS_AGENT, AXIS_VARIANT].filter(Boolean).map(slugifyBranch).filter(Boolean)
84
+ const joinedRaw = parts.length ? parts.join('-') : 'axis-preview'
85
+
86
+ // budget = SUBDOMAIN_MAX − "--" − siteName − hex − "-" between hex and joined
87
+ const hexOverhead = HEX_LEN + 1
88
+ const maxJoinedLen = Math.max(1, SUBDOMAIN_MAX - 2 - siteName.length - hexOverhead)
89
+ const joined = joinedRaw.slice(0, maxJoinedLen).replace(/-+$/, '')
90
+
91
+ const hex = Math.random().toString(16).slice(2, 2 + HEX_LEN)
92
+ return `${hex}-${joined}`
93
+ }
94
+
95
+ /** Default message describes the axis combination so Netlify deploy lists are scannable. */
96
+ const envDefaultMessage = () => {
97
+ const { AXIS_AGENT, AXIS_MODEL, AXIS_VARIANT } = process.env
98
+ const parts = []
99
+ if (AXIS_AGENT) parts.push(`agent=${AXIS_AGENT}`)
100
+ if (AXIS_MODEL) parts.push(`model=${AXIS_MODEL}`)
101
+ if (AXIS_VARIANT) parts.push(`variant=${AXIS_VARIANT}`)
102
+ return parts.length ? `axis · ${parts.join(' · ')}` : 'axis scenario teardown'
103
+ }
104
+
105
+ const readCliOptions = () => {
106
+ const { values } = parseArgs({
107
+ args: rawArgv,
108
+ options: {
109
+ name: { type: 'string' },
110
+ dir: { type: 'string', default: '.' },
111
+ account: { type: 'string' },
112
+ message: { type: 'string' },
113
+ branch: { type: 'string' },
114
+ },
115
+ strict: false,
116
+ })
117
+ return {
118
+ ...values,
119
+ message: values.message ?? envDefaultMessage(),
120
+ branch: values.branch ?? envDefaultBranch(typeof values.name === 'string' ? values.name : ''),
121
+ }
122
+ }
123
+
124
+ const validateName = (name) => {
125
+ if (typeof name !== 'string' || name.length === 0) {
126
+ throw new Error(`--name must be a non-empty string. Got: ${typeof name} ${JSON.stringify(name)}`)
127
+ }
128
+ if (!SITE_NAME_PATTERN.test(name)) {
129
+ throw new Error(
130
+ `--name "${name}" is not a valid Netlify subdomain. ` +
131
+ `Must be lowercase letters/digits/hyphens, 1-63 chars, no leading or trailing hyphen.`,
132
+ )
133
+ }
134
+ }
135
+
136
+ const apiFetch = async (path, init = {}) => {
137
+ const url = path.startsWith('http') ? path : `${NETLIFY_API}${path}`
138
+ const res = await fetch(url, {
139
+ ...init,
140
+ headers: {
141
+ Authorization: `Bearer ${process.env.NETLIFY_AUTH_TOKEN}`,
142
+ 'Content-Type': 'application/json',
143
+ ...(init.headers ?? {}),
144
+ },
145
+ })
146
+ if (!res.ok) {
147
+ const body = await res.text().catch(() => '')
148
+ throw new Error(`${init.method ?? 'GET'} ${path} → ${res.status}: ${body}`)
149
+ }
150
+ if (res.status === 204) return null
151
+ return res.json()
152
+ }
153
+
154
+ const NETLIFY_ID_PATTERN = /^[a-f0-9]{24}$/i
155
+
156
+ let cachedAccountSlug
157
+ const resolveAccountSlug = async (idOrSlug) => {
158
+ if (!idOrSlug) return undefined
159
+ if (!NETLIFY_ID_PATTERN.test(idOrSlug)) return idOrSlug
160
+ if (cachedAccountSlug) return cachedAccountSlug
161
+ const accounts = await apiFetch('/accounts')
162
+ if (!Array.isArray(accounts)) throw new Error('GET /accounts returned non-array')
163
+ const match = accounts.find((a) => a.id === idOrSlug)
164
+ if (!match) {
165
+ throw new Error(`Netlify account id ${idOrSlug} is not accessible by this token (NETLIFY_AUTH_TOKEN).`)
166
+ }
167
+ cachedAccountSlug = match.slug
168
+ return cachedAccountSlug
169
+ }
170
+
171
+ const findSiteByName = async (name, accountSlug) => {
172
+ const params = new URLSearchParams({ filter: 'all', name })
173
+ if (accountSlug) params.set('account_slug', accountSlug)
174
+ const sites = await apiFetch(`/sites?${params.toString()}`)
175
+ if (!Array.isArray(sites)) return null
176
+ return sites.find((s) => s.name === name) ?? null
177
+ }
178
+
179
+ const isSubdomainTakenError = (err) =>
180
+ /\b422\b/.test(String(err?.message ?? '')) && /subdomain.*must be unique/i.test(String(err?.message ?? ''))
181
+
182
+ const createSite = async (name, accountSlug) => {
183
+ const path = accountSlug ? `/${encodeURIComponent(accountSlug)}/sites` : '/sites'
184
+ return apiFetch(path, { method: 'POST', body: JSON.stringify({ name }) })
185
+ }
186
+
187
+ const lookupOrCreateSite = async (name, accountSlug) => {
188
+ const existing = await findSiteByName(name, accountSlug)
189
+ if (existing) return existing
190
+ try {
191
+ return await createSite(name, accountSlug)
192
+ } catch (err) {
193
+ if (!isSubdomainTakenError(err)) throw err
194
+ const retry = await findSiteByName(name, accountSlug)
195
+ if (retry) return retry
196
+ throw new Error(
197
+ `Netlify site name '${name}' is taken by another account this token cannot access. ` +
198
+ `Pick a different siteName or grant this token access to the existing site.`,
199
+ )
200
+ }
201
+ }
202
+
203
+ const ensureSourceDir = async (dir) => {
204
+ let info
205
+ try {
206
+ info = await stat(dir)
207
+ } catch (err) {
208
+ throw new Error(`source dir does not exist: ${dir} (${err.code})`)
209
+ }
210
+ if (!info.isDirectory()) throw new Error(`source dir is not a directory: ${dir}`)
211
+ }
212
+
213
+ const zipSource = async (sourceDir) => {
214
+ const tmp = await mkdtemp(join(tmpdir(), 'axis-deploy-'))
215
+ const zipPath = join(tmp, 'source.zip')
216
+ const excludeArgs = IGNORE_PATTERNS.flatMap((p) => ['-x', p])
217
+ await execFileAsync('zip', ['-r', '-q', zipPath, '.', ...excludeArgs], {
218
+ cwd: sourceDir,
219
+ maxBuffer: 100 * 1024 * 1024,
220
+ })
221
+ return zipPath
222
+ }
223
+
224
+ const triggerBuildFromZip = async ({ siteId, zipPath, title, branch }) => {
225
+ const body = await readFile(zipPath)
226
+ const params = new URLSearchParams({ title })
227
+ if (branch) params.set('branch', branch)
228
+ const url = `${NETLIFY_API}/sites/${siteId}/builds?${params.toString()}`
229
+ const res = await fetch(url, {
230
+ method: 'POST',
231
+ body,
232
+ headers: {
233
+ Authorization: `Bearer ${process.env.NETLIFY_AUTH_TOKEN}`,
234
+ 'Content-Type': 'application/zip',
235
+ 'Content-Length': String(body.length),
236
+ },
237
+ })
238
+ if (!res.ok) {
239
+ const text = await res.text().catch(() => '')
240
+ throw new Error(`POST /sites/${siteId}/builds → ${res.status}: ${text}`)
241
+ }
242
+ return res.json()
243
+ }
244
+
245
+ const fetchDeploy = async (deployId) => {
246
+ try {
247
+ return await apiFetch(`/deploys/${deployId}`)
248
+ } catch {
249
+ return null
250
+ }
251
+ }
252
+
253
+ const writeAxisOutput = async (markdown) => {
254
+ const path = process.env.AXIS_OUTPUT
255
+ if (!path) return
256
+ await appendFile(path, markdown.endsWith('\n') ? markdown : `${markdown}\n`)
257
+ }
258
+
259
+ /**
260
+ * Markdown body appended to `$AXIS_OUTPUT`. Renders as clickable links in the
261
+ * axis report. Falls back gracefully when fields are still provisioning.
262
+ */
263
+ const buildReportMarkdown = ({ siteName, site, build, deploy, branch }) => {
264
+ const previewUrl = deploy?.deploy_ssl_url || deploy?.deploy_url
265
+ const branchUrl = branch && site?.ssl_url ? site.ssl_url.replace(/^https:\/\//, `https://${branch}--`) : null
266
+ const liveUrl = previewUrl || branchUrl
267
+ const adminLogsUrl = `${site.admin_url}/deploys/${build.deploy_id}`
268
+ const sitePageUrl = site.admin_url
269
+
270
+ const links = []
271
+ if (liveUrl) links.push(`[Live preview](${liveUrl})`)
272
+ links.push(`[Build logs](${adminLogsUrl})`)
273
+ links.push(`[Site dashboard](${sitePageUrl})`)
274
+
275
+ return `## Netlify deploy — \`${siteName}\`\n\n${links.join(' · ')}\n`
276
+ }
277
+
278
+ const main = async () => {
279
+ const { name, dir, account, message, branch } = readCliOptions()
280
+ validateName(name)
281
+ if (!process.env.NETLIFY_AUTH_TOKEN) throw new Error('NETLIFY_AUTH_TOKEN is not set')
282
+
283
+ await ensureSourceDir(dir)
284
+ const accountSlug = await resolveAccountSlug(account)
285
+ const site = await lookupOrCreateSite(name, accountSlug)
286
+ const zipPath = await zipSource(dir)
287
+ const build = await triggerBuildFromZip({ siteId: site.id, zipPath, title: message, branch })
288
+ const deploy = await fetchDeploy(build.deploy_id)
289
+
290
+ await writeAxisOutput(buildReportMarkdown({ siteName: name, site, build, deploy, branch }))
291
+
292
+ process.stdout.write(
293
+ JSON.stringify({
294
+ siteId: site.id,
295
+ siteName: site.name,
296
+ siteUrl: site.ssl_url || site.url,
297
+ buildId: build.id,
298
+ deployId: build.deploy_id,
299
+ deployUrl: deploy?.deploy_ssl_url || deploy?.deploy_url,
300
+ adminLogsUrl: `${site.admin_url}/deploys/${build.deploy_id}`,
301
+ }) + '\n',
302
+ )
303
+ }
304
+
305
+ main().catch((err) => {
306
+ // Include argv on error so argument-passing bugs (e.g. shells dropping a
307
+ // quoted value) are visible without dumping argv on every successful run.
308
+ process.stderr.write(`[deploy] error: ${err.message ?? err}\n`)
309
+ process.stderr.write(`[deploy] argv: ${JSON.stringify(rawArgv)}\n`)
310
+ process.exit(1)
311
+ })
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Populates a per-scenario local mirror of `netlify/swar-templates` inside the
4
+ * current AXIS workspace, at the path `@netlify/ts-cli` falls back to when its
5
+ * GitHub fetch is blocked by the agent sandbox.
6
+ *
7
+ * The path comes from the same constant the create-mode prompt uses
8
+ * (`TS_CLI_TEMPLATE_MIRROR_FALLBACK_BASE` in `src/stages/create.ts`), passed
9
+ * in via env so production and axis stay in sync without this script having
10
+ * to import TypeScript.
11
+ *
12
+ * Env vars:
13
+ * - `AXIS_WORKSPACE` — set by axis on every lifecycle script; the
14
+ * scenario's workspace dir.
15
+ * - `TS_CLI_TEMPLATE_MIRROR_BASE` — passed by the helper that schedules
16
+ * this script (`scenarioPrewarmTemplateMirror` in
17
+ * `scenarios/helpers/scenario-prompts.ts`); should match the default
18
+ * value emitted in the create-mode scaffold command.
19
+ *
20
+ * ts-cli wipes the mirror via `cleanupLocalMirror()` after each scaffold, but
21
+ * that's fine — axis tears the workspace down at the end of the scenario, so
22
+ * the mirror has no lifetime beyond a single ts-cli run.
23
+ */
24
+ import { execSync } from 'node:child_process'
25
+ import { existsSync, mkdirSync } from 'node:fs'
26
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
27
+ import process from 'node:process'
28
+
29
+ const REPO = 'https://github.com/netlify/swar-templates.git'
30
+
31
+ const workspace = process.env.AXIS_WORKSPACE
32
+ const mirrorBase = process.env.TS_CLI_TEMPLATE_MIRROR_BASE
33
+
34
+ if (!workspace) {
35
+ process.stderr.write('[prewarm-template-mirror] AXIS_WORKSPACE is not set (run this from an axis setup phase)\n')
36
+ process.exit(1)
37
+ }
38
+ if (!mirrorBase) {
39
+ process.stderr.write(
40
+ '[prewarm-template-mirror] TS_CLI_TEMPLATE_MIRROR_BASE is not set (pass it via the scenario helper)\n',
41
+ )
42
+ process.exit(1)
43
+ }
44
+
45
+ const absBase = isAbsolute(mirrorBase) ? mirrorBase : resolve(workspace, mirrorBase)
46
+ const mirrorDir = join(absBase, 'cache', 'cwd', 'swar-templates')
47
+ const manifestPath = join(mirrorDir, 'manifest.json')
48
+
49
+ if (existsSync(manifestPath)) {
50
+ process.stderr.write(`[prewarm-template-mirror] mirror already populated at ${mirrorDir}\n`)
51
+ process.exit(0)
52
+ }
53
+
54
+ mkdirSync(dirname(mirrorDir), { recursive: true })
55
+ process.stderr.write(`[prewarm-template-mirror] cloning ${REPO} → ${mirrorDir}\n`)
56
+
57
+ try {
58
+ execSync(`git clone --depth 1 "${REPO}" "${mirrorDir}"`, { stdio: 'inherit' })
59
+ } catch (err) {
60
+ process.stderr.write(`[prewarm-template-mirror] git clone failed: ${err.message ?? err}\n`)
61
+ process.exit(1)
62
+ }
63
+
64
+ if (!existsSync(manifestPath)) {
65
+ process.stderr.write(`[prewarm-template-mirror] clone succeeded but ${manifestPath} is missing\n`)
66
+ process.exit(1)
67
+ }
68
+
69
+ process.stderr.write(`[prewarm-template-mirror] ready at ${mirrorDir}\n`)