neonctl 2.26.7 → 2.27.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.
- package/commands/bootstrap.js +14 -81
- package/package.json +2 -2
- package/utils/bootstrap.js +0 -364
package/commands/bootstrap.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join, relative, resolve } from 'node:path';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
|
+
import { BootstrapInputError, FALLBACK_TEMPLATES, ensureTargetUsable, fetchTemplates, findTemplate, scaffoldTemplate, templateIds, } from 'neon-init/bootstrap';
|
|
5
6
|
import prompts from 'prompts';
|
|
6
7
|
import which from 'which';
|
|
7
8
|
import { isCi } from '../env.js';
|
|
8
9
|
import { log } from '../log.js';
|
|
9
|
-
import { FALLBACK_TEMPLATES, downloadTemplate, fetchTemplates, findTemplate, templateIds, } from '../utils/bootstrap.js';
|
|
10
10
|
// The directory positional is optional: omitting it in an interactive terminal
|
|
11
11
|
// prompts for one. In a non-interactive context a missing directory is an error.
|
|
12
12
|
export const command = 'bootstrap [directory]';
|
|
@@ -178,70 +178,20 @@ const resolveTargetDir = async (props, interactive, template) => {
|
|
|
178
178
|
};
|
|
179
179
|
const defaultDirName = (template) => template.source.subdir.split('/').pop() || template.id;
|
|
180
180
|
/**
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
181
|
+
* Download and materialize the template into `targetDir`. The actual
|
|
182
|
+
* download/extract/write lives in the shared `neon-init/bootstrap` core
|
|
183
|
+
* (exec-bit and symlink fidelity, graceful symlink fallback); here we just
|
|
184
|
+
* frame it with progress logging. Returns the number of files written.
|
|
185
185
|
*/
|
|
186
|
-
class BootstrapInputError extends Error {
|
|
187
|
-
constructor(message, agentCode) {
|
|
188
|
-
super(message);
|
|
189
|
-
this.name = 'BootstrapInputError';
|
|
190
|
-
this.agentCode = agentCode;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
const ensureTargetUsable = (dir, force) => {
|
|
194
|
-
if (!existsSync(dir)) {
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
if (!statSync(dir).isDirectory()) {
|
|
198
|
-
throw new BootstrapInputError(`Target ${dir} already exists and is not a directory.`, 'TARGET_NOT_DIRECTORY');
|
|
199
|
-
}
|
|
200
|
-
// A lone `.git` is ignored so you can scaffold into a freshly `git init`ed
|
|
201
|
-
// (otherwise empty) directory without reaching for --force.
|
|
202
|
-
const contents = readdirSync(dir).filter((name) => name !== '.git');
|
|
203
|
-
if (contents.length > 0 && !force) {
|
|
204
|
-
throw new BootstrapInputError(`Target directory ${dir} is not empty. Use --force to scaffold into it anyway (colliding files will be overwritten), or choose an empty directory.`, 'TARGET_NOT_EMPTY');
|
|
205
|
-
}
|
|
206
|
-
};
|
|
207
186
|
const scaffold = async (template, targetDir) => {
|
|
208
187
|
log.info('Fetching template "%s" from GitHub…', template.id);
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
writeSymlink(dest, file.target);
|
|
217
|
-
}
|
|
218
|
-
else {
|
|
219
|
-
writeFileSync(dest, file.bytes);
|
|
220
|
-
if (file.executable) {
|
|
221
|
-
chmodSync(dest, 0o755);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
return files.length;
|
|
226
|
-
};
|
|
227
|
-
const writeSymlink = (dest, target) => {
|
|
228
|
-
if (isSymlink(dest)) {
|
|
229
|
-
rmSync(dest, { force: true });
|
|
230
|
-
}
|
|
231
|
-
try {
|
|
232
|
-
symlinkSync(target, dest);
|
|
233
|
-
}
|
|
234
|
-
catch (err) {
|
|
235
|
-
// Windows refuses symlinks without elevated rights / developer mode. The
|
|
236
|
-
// template still works for most tooling if we drop a regular file holding
|
|
237
|
-
// the link target, so we degrade gracefully instead of failing the copy.
|
|
238
|
-
if (errnoCode(err) === 'EPERM' || process.platform === 'win32') {
|
|
239
|
-
log.warning('Could not create symlink %s -> %s; wrote it as a regular file instead.', dest, target);
|
|
240
|
-
writeFileSync(dest, target);
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
throw err;
|
|
244
|
-
}
|
|
188
|
+
const filesWritten = await scaffoldTemplate(template, targetDir, {
|
|
189
|
+
onWarn: (message) => {
|
|
190
|
+
log.warning(message);
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
log.info('Scaffolded %d files into %s.', filesWritten, targetDir);
|
|
194
|
+
return filesWritten;
|
|
245
195
|
};
|
|
246
196
|
// ----------------------------------------------------------------------------
|
|
247
197
|
// Post-scaffold steps (install dependencies, git init, link to a Neon project)
|
|
@@ -558,23 +508,6 @@ const displayDir = (targetDir) => {
|
|
|
558
508
|
}
|
|
559
509
|
return rel.startsWith('..') ? targetDir : rel;
|
|
560
510
|
};
|
|
561
|
-
const isSymlink = (path) => {
|
|
562
|
-
try {
|
|
563
|
-
return lstatSync(path).isSymbolicLink();
|
|
564
|
-
}
|
|
565
|
-
catch {
|
|
566
|
-
return false;
|
|
567
|
-
}
|
|
568
|
-
};
|
|
569
|
-
const errnoCode = (err) => {
|
|
570
|
-
if (typeof err === 'object' &&
|
|
571
|
-
err !== null &&
|
|
572
|
-
'code' in err &&
|
|
573
|
-
typeof err.code === 'string') {
|
|
574
|
-
return err.code;
|
|
575
|
-
}
|
|
576
|
-
return undefined;
|
|
577
|
-
};
|
|
578
511
|
const shellArg = (value) => {
|
|
579
512
|
if (/^[A-Za-z0-9._:/-]+$/.test(value)) {
|
|
580
513
|
return value;
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"url": "git+ssh://git@github.com/neondatabase/neonctl.git"
|
|
6
6
|
},
|
|
7
7
|
"type": "module",
|
|
8
|
-
"version": "2.
|
|
8
|
+
"version": "2.27.0",
|
|
9
9
|
"description": "CLI tool for NeonDB Cloud management",
|
|
10
10
|
"main": "index.js",
|
|
11
11
|
"author": "NeonDB",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"cliui": "8.0.1",
|
|
72
72
|
"diff": "5.2.0",
|
|
73
73
|
"fflate": "^0.8.3",
|
|
74
|
-
"neon-init": "0.
|
|
74
|
+
"neon-init": "0.19.0",
|
|
75
75
|
"open": "10.1.0",
|
|
76
76
|
"openid-client": "6.8.1",
|
|
77
77
|
"pg-protocol": "^1.14.0",
|
package/utils/bootstrap.js
DELETED
|
@@ -1,364 +0,0 @@
|
|
|
1
|
-
import axios, { isAxiosError } from 'axios';
|
|
2
|
-
import { gunzipSync } from 'fflate';
|
|
3
|
-
import YAML from 'yaml';
|
|
4
|
-
import { log } from '../log.js';
|
|
5
|
-
/**
|
|
6
|
-
* Hardcoded fallback used when every remote manifest source is unreachable.
|
|
7
|
-
* Kept in sync with `neondatabase/examples/bootstrap.yaml` (the source of
|
|
8
|
-
* truth) so that, even fully offline from the manifest, the picker still offers
|
|
9
|
-
* the full set of starters rather than a single template.
|
|
10
|
-
*/
|
|
11
|
-
export const FALLBACK_TEMPLATES = [
|
|
12
|
-
{
|
|
13
|
-
id: 'hono',
|
|
14
|
-
title: 'Hono API (Drizzle, Neon Postgres) on Neon Functions',
|
|
15
|
-
description: 'A Hono API using Drizzle ORM and Neon Postgres, ready to deploy as a Neon Function.',
|
|
16
|
-
services: ['Postgres', 'Functions'],
|
|
17
|
-
source: {
|
|
18
|
-
owner: 'neondatabase',
|
|
19
|
-
repo: 'examples',
|
|
20
|
-
ref: 'main',
|
|
21
|
-
subdir: 'with-hono',
|
|
22
|
-
},
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
id: 'ai-sdk',
|
|
26
|
-
title: 'AI SDK agent (AI Gateway, object storage, Drizzle) on Neon Functions',
|
|
27
|
-
description: 'A Vercel AI SDK agent on Neon Functions: streams chat through the Neon AI Gateway, generates an image with OpenAI image generation, and stores it in Neon object storage indexed in Postgres via Drizzle.',
|
|
28
|
-
services: ['Postgres', 'Functions', 'Object Storage', 'AI Gateway'],
|
|
29
|
-
source: {
|
|
30
|
-
owner: 'neondatabase',
|
|
31
|
-
repo: 'examples',
|
|
32
|
-
ref: 'main',
|
|
33
|
-
subdir: 'with-ai-sdk',
|
|
34
|
-
},
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
id: 'mastra',
|
|
38
|
-
title: 'Mastra personal agent (AI Gateway, Mastra Memory) on Neon Functions',
|
|
39
|
-
description: 'A Mastra personal-assistant agent on Neon Functions: streams chat through the Neon AI Gateway and uses Mastra Memory — backed by Neon Postgres — to remember the user across conversation threads via resource-scoped working memory.',
|
|
40
|
-
services: ['Postgres', 'Functions', 'AI Gateway'],
|
|
41
|
-
source: {
|
|
42
|
-
owner: 'neondatabase',
|
|
43
|
-
repo: 'examples',
|
|
44
|
-
ref: 'main',
|
|
45
|
-
subdir: 'with-mastra',
|
|
46
|
-
},
|
|
47
|
-
},
|
|
48
|
-
];
|
|
49
|
-
export const templateIds = (templates) => templates.map((t) => t.id).join(', ');
|
|
50
|
-
export const findTemplate = (templates, id) => templates.find((t) => t.id === id);
|
|
51
|
-
const githubToken = () => process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? '';
|
|
52
|
-
// A token is never required for public templates, but we forward it when
|
|
53
|
-
// present so the same code path works behind proxies that authenticate, and
|
|
54
|
-
// (in future) for private template repos.
|
|
55
|
-
const downloadHeaders = () => ({
|
|
56
|
-
'User-Agent': 'neonctl',
|
|
57
|
-
...(githubToken() ? { Authorization: `Bearer ${githubToken()}` } : {}),
|
|
58
|
-
});
|
|
59
|
-
// The codeload host is overridable so the e2e tests can point the downloader at
|
|
60
|
-
// a local server (the same trick `--api-host` uses to redirect the Neon API).
|
|
61
|
-
const codeloadBase = () => process.env.NEON_BOOTSTRAP_GITHUB_CODELOAD ?? 'https://codeload.github.com';
|
|
62
|
-
const isRecord = (value) => typeof value === 'object' && value !== null;
|
|
63
|
-
/**
|
|
64
|
-
* Normalize a manifest entry's `services` into a clean string list. Tolerant by
|
|
65
|
-
* design: a missing or non-array value yields `undefined`, and non-string items
|
|
66
|
-
* are dropped, so a malformed `services` never sinks an otherwise-valid
|
|
67
|
-
* template (it just renders without its badge).
|
|
68
|
-
*/
|
|
69
|
-
const parseServices = (value) => {
|
|
70
|
-
if (!Array.isArray(value)) {
|
|
71
|
-
return undefined;
|
|
72
|
-
}
|
|
73
|
-
const services = value.filter((item) => typeof item === 'string' && item.trim() !== '');
|
|
74
|
-
return services.length > 0 ? services : undefined;
|
|
75
|
-
};
|
|
76
|
-
// ---------------------------------------------------------------------------
|
|
77
|
-
// Remote template manifest
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
|
-
// Primary manifest host is neon.com (CDN-backed, no GitHub rate limiting),
|
|
80
|
-
// with the raw GitHub copy as a fallback and the hardcoded list as the last
|
|
81
|
-
// resort. A single env override (used by tests) short-circuits the chain.
|
|
82
|
-
const NEON_MANIFEST_URL = 'https://neon.com/bootstrap/templates.yaml';
|
|
83
|
-
const GITHUB_RAW_MANIFEST_URL = 'https://raw.githubusercontent.com/neondatabase/examples/main/bootstrap.yaml';
|
|
84
|
-
const manifestUrls = () => {
|
|
85
|
-
const override = process.env.NEON_BOOTSTRAP_MANIFEST_URL;
|
|
86
|
-
if (override) {
|
|
87
|
-
return [override];
|
|
88
|
-
}
|
|
89
|
-
return [NEON_MANIFEST_URL, GITHUB_RAW_MANIFEST_URL];
|
|
90
|
-
};
|
|
91
|
-
export const parseManifest = (text) => {
|
|
92
|
-
const data = YAML.parse(text);
|
|
93
|
-
if (!isRecord(data) || !Array.isArray(data.templates)) {
|
|
94
|
-
throw new Error('Invalid bootstrap manifest: missing "templates" array.');
|
|
95
|
-
}
|
|
96
|
-
const templates = [];
|
|
97
|
-
for (let i = 0; i < data.templates.length; i++) {
|
|
98
|
-
const item = data.templates[i];
|
|
99
|
-
if (!isRecord(item) ||
|
|
100
|
-
typeof item.id !== 'string' ||
|
|
101
|
-
typeof item.title !== 'string' ||
|
|
102
|
-
typeof item.description !== 'string' ||
|
|
103
|
-
!isRecord(item.source) ||
|
|
104
|
-
typeof item.source.owner !== 'string' ||
|
|
105
|
-
typeof item.source.repo !== 'string' ||
|
|
106
|
-
typeof item.source.ref !== 'string' ||
|
|
107
|
-
typeof item.source.subdir !== 'string') {
|
|
108
|
-
log.warning('bootstrap: skipping malformed template entry at index %d in manifest.', i);
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
const services = parseServices(item.services);
|
|
112
|
-
templates.push({
|
|
113
|
-
id: item.id,
|
|
114
|
-
title: item.title,
|
|
115
|
-
description: item.description,
|
|
116
|
-
...(services ? { services } : {}),
|
|
117
|
-
source: {
|
|
118
|
-
owner: item.source.owner,
|
|
119
|
-
repo: item.source.repo,
|
|
120
|
-
ref: item.source.ref,
|
|
121
|
-
subdir: item.source.subdir,
|
|
122
|
-
},
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
return templates;
|
|
126
|
-
};
|
|
127
|
-
/**
|
|
128
|
-
* Fetch the template manifest, trying each source in {@link manifestUrls} in
|
|
129
|
-
* order and returning the first that yields a non-empty template list. Falls
|
|
130
|
-
* back to the hardcoded list when every source is unreachable or empty, so the
|
|
131
|
-
* command never fails just because a host is down.
|
|
132
|
-
*/
|
|
133
|
-
export const fetchTemplates = async () => {
|
|
134
|
-
for (const url of manifestUrls()) {
|
|
135
|
-
try {
|
|
136
|
-
const res = await axios.get(url, {
|
|
137
|
-
responseType: 'text',
|
|
138
|
-
headers: downloadHeaders(),
|
|
139
|
-
timeout: 10000,
|
|
140
|
-
});
|
|
141
|
-
const templates = parseManifest(res.data);
|
|
142
|
-
if (templates.length > 0) {
|
|
143
|
-
return templates;
|
|
144
|
-
}
|
|
145
|
-
log.debug('bootstrap: manifest at %s contained no templates; trying next source.', url);
|
|
146
|
-
}
|
|
147
|
-
catch (err) {
|
|
148
|
-
log.debug('bootstrap: failed to fetch manifest from %s: %s — trying next source.', url, err instanceof Error ? err.message : String(err));
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
log.debug('bootstrap: all manifest sources exhausted; using built-in defaults.');
|
|
152
|
-
return FALLBACK_TEMPLATES;
|
|
153
|
-
};
|
|
154
|
-
const TAR_BLOCK = 512;
|
|
155
|
-
const readTarString = (buf, offset, length) => {
|
|
156
|
-
let end = offset;
|
|
157
|
-
const max = offset + length;
|
|
158
|
-
while (end < max && buf[end] !== 0) {
|
|
159
|
-
end++;
|
|
160
|
-
}
|
|
161
|
-
return buf.toString('utf8', offset, end);
|
|
162
|
-
};
|
|
163
|
-
const readTarOctal = (buf, offset, length) => {
|
|
164
|
-
const text = readTarString(buf, offset, length).trim();
|
|
165
|
-
if (text === '') {
|
|
166
|
-
return 0;
|
|
167
|
-
}
|
|
168
|
-
const value = parseInt(text, 8);
|
|
169
|
-
return Number.isNaN(value) ? 0 : value;
|
|
170
|
-
};
|
|
171
|
-
const isZeroBlock = (buf, offset) => {
|
|
172
|
-
for (let i = offset; i < offset + TAR_BLOCK; i++) {
|
|
173
|
-
if (buf[i] !== 0) {
|
|
174
|
-
return false;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
return true;
|
|
178
|
-
};
|
|
179
|
-
/**
|
|
180
|
-
* Parse pax extended-header records ("<len> <key>=<value>\n"). GitHub uses
|
|
181
|
-
* these for the global header and for any path that doesn't fit the legacy
|
|
182
|
-
* 100-byte name field, so we must honor at least `path` and `linkpath`.
|
|
183
|
-
*/
|
|
184
|
-
const parsePaxRecords = (data) => {
|
|
185
|
-
const records = {};
|
|
186
|
-
let pos = 0;
|
|
187
|
-
const text = data.toString('utf8');
|
|
188
|
-
while (pos < text.length) {
|
|
189
|
-
const space = text.indexOf(' ', pos);
|
|
190
|
-
if (space === -1) {
|
|
191
|
-
break;
|
|
192
|
-
}
|
|
193
|
-
const len = parseInt(text.slice(pos, space), 10);
|
|
194
|
-
if (Number.isNaN(len) || len <= 0) {
|
|
195
|
-
break;
|
|
196
|
-
}
|
|
197
|
-
const record = text.slice(space + 1, pos + len - 1); // drop trailing "\n"
|
|
198
|
-
const eq = record.indexOf('=');
|
|
199
|
-
if (eq !== -1) {
|
|
200
|
-
records[record.slice(0, eq)] = record.slice(eq + 1);
|
|
201
|
-
}
|
|
202
|
-
pos += len;
|
|
203
|
-
}
|
|
204
|
-
return records;
|
|
205
|
-
};
|
|
206
|
-
/**
|
|
207
|
-
* Decode a (decompressed) tar archive into its file/symlink entries. Pure and
|
|
208
|
-
* dependency-free so it can be unit tested without touching the network.
|
|
209
|
-
* Handles the ustar `prefix` field, pax extended headers (type 'x'/'g'), and
|
|
210
|
-
* GNU long-name/long-link headers (type 'L'/'K') so deep template paths and
|
|
211
|
-
* long symlink targets round-trip correctly.
|
|
212
|
-
*/
|
|
213
|
-
export const parseTar = (buf) => {
|
|
214
|
-
const entries = [];
|
|
215
|
-
// Overrides carried from a preceding pax/GNU header to the next real entry.
|
|
216
|
-
let overridePath;
|
|
217
|
-
let overrideLink;
|
|
218
|
-
let offset = 0;
|
|
219
|
-
while (offset + TAR_BLOCK <= buf.length) {
|
|
220
|
-
if (isZeroBlock(buf, offset)) {
|
|
221
|
-
break;
|
|
222
|
-
}
|
|
223
|
-
let name = readTarString(buf, offset, 100);
|
|
224
|
-
const mode = readTarOctal(buf, offset + 100, 8);
|
|
225
|
-
const size = readTarOctal(buf, offset + 124, 12);
|
|
226
|
-
const typeByte = buf[offset + 156];
|
|
227
|
-
const type = typeByte === 0 ? '0' : String.fromCharCode(typeByte);
|
|
228
|
-
let linkname = readTarString(buf, offset + 157, 100);
|
|
229
|
-
const magic = readTarString(buf, offset + 257, 6);
|
|
230
|
-
if (magic.startsWith('ustar')) {
|
|
231
|
-
const prefix = readTarString(buf, offset + 345, 155);
|
|
232
|
-
if (prefix !== '') {
|
|
233
|
-
name = `${prefix}/${name}`;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
offset += TAR_BLOCK;
|
|
237
|
-
const data = buf.subarray(offset, offset + size);
|
|
238
|
-
offset += Math.ceil(size / TAR_BLOCK) * TAR_BLOCK;
|
|
239
|
-
if (type === 'x') {
|
|
240
|
-
const records = parsePaxRecords(data);
|
|
241
|
-
if (records.path !== undefined) {
|
|
242
|
-
overridePath = records.path;
|
|
243
|
-
}
|
|
244
|
-
if (records.linkpath !== undefined) {
|
|
245
|
-
overrideLink = records.linkpath;
|
|
246
|
-
}
|
|
247
|
-
continue;
|
|
248
|
-
}
|
|
249
|
-
if (type === 'g') {
|
|
250
|
-
// Global pax header (e.g. GitHub's comment block): not per-entry state.
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
if (type === 'L' || type === 'K') {
|
|
254
|
-
const longValue = data.toString('utf8').replace(/\0+$/, '');
|
|
255
|
-
if (type === 'L') {
|
|
256
|
-
overridePath = longValue;
|
|
257
|
-
}
|
|
258
|
-
else {
|
|
259
|
-
overrideLink = longValue;
|
|
260
|
-
}
|
|
261
|
-
continue;
|
|
262
|
-
}
|
|
263
|
-
if (overridePath !== undefined) {
|
|
264
|
-
name = overridePath;
|
|
265
|
-
}
|
|
266
|
-
if (overrideLink !== undefined) {
|
|
267
|
-
linkname = overrideLink;
|
|
268
|
-
}
|
|
269
|
-
overridePath = undefined;
|
|
270
|
-
overrideLink = undefined;
|
|
271
|
-
entries.push({ name, type, mode, linkname, data: Buffer.from(data) });
|
|
272
|
-
}
|
|
273
|
-
return entries;
|
|
274
|
-
};
|
|
275
|
-
/**
|
|
276
|
-
* Map decoded tar entries to the files under `subdir`, with the top-level
|
|
277
|
-
* archive directory and the `subdir/` prefix stripped from each path. Pure so
|
|
278
|
-
* it can be unit tested. Directory and other non-regular entries are dropped —
|
|
279
|
-
* writing files re-creates their parent directories.
|
|
280
|
-
*/
|
|
281
|
-
export const selectTemplateFiles = (entries, subdir) => {
|
|
282
|
-
const prefix = `${subdir.replace(/^\/+|\/+$/g, '')}/`;
|
|
283
|
-
const files = [];
|
|
284
|
-
for (const entry of entries) {
|
|
285
|
-
// codeload wraps everything in a single top-level dir ("<repo>-<ref>/");
|
|
286
|
-
// strip that first segment to get the repo-relative path.
|
|
287
|
-
const slash = entry.name.indexOf('/');
|
|
288
|
-
if (slash === -1) {
|
|
289
|
-
continue;
|
|
290
|
-
}
|
|
291
|
-
const repoPath = entry.name.slice(slash + 1);
|
|
292
|
-
if (!repoPath.startsWith(prefix)) {
|
|
293
|
-
continue;
|
|
294
|
-
}
|
|
295
|
-
const path = repoPath.slice(prefix.length);
|
|
296
|
-
if (path === '') {
|
|
297
|
-
continue;
|
|
298
|
-
}
|
|
299
|
-
if (entry.type === '2') {
|
|
300
|
-
files.push({ kind: 'symlink', path, target: entry.linkname });
|
|
301
|
-
}
|
|
302
|
-
else if (entry.type === '0' || entry.type === '7') {
|
|
303
|
-
files.push({
|
|
304
|
-
kind: 'file',
|
|
305
|
-
path,
|
|
306
|
-
bytes: entry.data,
|
|
307
|
-
executable: (entry.mode & 0o111) !== 0,
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
// Directories ('5') and any other node types are intentionally skipped.
|
|
311
|
-
}
|
|
312
|
-
return files;
|
|
313
|
-
};
|
|
314
|
-
const tarballUrl = (template) => {
|
|
315
|
-
const { owner, repo, ref } = template.source;
|
|
316
|
-
return `${codeloadBase()}/${owner}/${repo}/tar.gz/${ref}`;
|
|
317
|
-
};
|
|
318
|
-
const friendlyGithubError = (err, url) => {
|
|
319
|
-
if (isAxiosError(err)) {
|
|
320
|
-
const status = err.response?.status;
|
|
321
|
-
if (status === 404) {
|
|
322
|
-
return new Error(`GitHub returned 404 for ${url}. The template repo or ref may have moved.`);
|
|
323
|
-
}
|
|
324
|
-
if (status === 403 || status === 429) {
|
|
325
|
-
return new Error(`GitHub rate limited the template download (${url}). Set a GITHUB_TOKEN environment variable to raise the limit, then retry.`);
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
return err instanceof Error ? err : new Error(String(err));
|
|
329
|
-
};
|
|
330
|
-
/**
|
|
331
|
-
* Download a template and resolve it to the exact set of files to write. The
|
|
332
|
-
* entire subtree is captured in one tarball request, so the copy is atomically
|
|
333
|
-
* consistent: a push to the template repo mid-download cannot produce a
|
|
334
|
-
* mismatched checkout (unlike fetching a file list and then each blob).
|
|
335
|
-
*/
|
|
336
|
-
export const downloadTemplate = async (template) => {
|
|
337
|
-
const url = tarballUrl(template);
|
|
338
|
-
let gzipped;
|
|
339
|
-
try {
|
|
340
|
-
const res = await axios.get(url, {
|
|
341
|
-
responseType: 'arraybuffer',
|
|
342
|
-
headers: downloadHeaders(),
|
|
343
|
-
timeout: 30000,
|
|
344
|
-
});
|
|
345
|
-
gzipped = Buffer.from(res.data);
|
|
346
|
-
}
|
|
347
|
-
catch (err) {
|
|
348
|
-
throw friendlyGithubError(err, url);
|
|
349
|
-
}
|
|
350
|
-
let tar;
|
|
351
|
-
try {
|
|
352
|
-
tar = Buffer.from(gunzipSync(new Uint8Array(gzipped)));
|
|
353
|
-
}
|
|
354
|
-
catch (err) {
|
|
355
|
-
throw new Error(`Failed to decompress the template archive from ${url}: ${err instanceof Error ? err.message : String(err)}`);
|
|
356
|
-
}
|
|
357
|
-
const { owner, repo, ref, subdir } = template.source;
|
|
358
|
-
const files = selectTemplateFiles(parseTar(tar), subdir);
|
|
359
|
-
if (files.length === 0) {
|
|
360
|
-
throw new Error(`Template subdirectory "${subdir}" was not found in ${owner}/${repo}@${ref}.`);
|
|
361
|
-
}
|
|
362
|
-
log.debug('bootstrap: resolved %d files for template "%s" from %s', files.length, template.id, url);
|
|
363
|
-
return files;
|
|
364
|
-
};
|