neonctl 2.26.1 → 2.26.3
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/README.md +1 -1
- package/commands/auth.js +7 -0
- package/commands/bootstrap.js +18 -31
- package/commands/bucket.js +27 -3
- package/commands/checkout.js +9 -1
- package/commands/config.js +37 -21
- package/commands/dev.js +2 -3
- package/commands/env.js +44 -5
- package/commands/functions.js +62 -19
- package/commands/init.js +30 -4
- package/env_file.js +28 -15
- package/functions_api.js +3 -2
- package/package.json +5 -5
- package/test_utils/fixtures.js +1 -1
- package/utils/bootstrap.js +247 -126
- package/utils/branch_notice.js +22 -0
- package/utils/enrichers.js +39 -0
- package/utils/esbuild.js +4 -5
- package/utils/zip.js +2 -2
package/env_file.js
CHANGED
|
@@ -16,35 +16,47 @@ export const resolveEnvFilePath = (cwd, file) => {
|
|
|
16
16
|
* Merge `updates` into the dotenv content at `path`, preserving every other line
|
|
17
17
|
* (comments, blank lines, unrelated keys) and the file's existing order. Keys present in
|
|
18
18
|
* both are updated in place; keys only in `updates` are appended. A non-existent file is
|
|
19
|
-
* treated as empty.
|
|
19
|
+
* treated as empty. When `managedKeys` is given, any owned key on disk that is absent from
|
|
20
|
+
* `updates` is removed. Returns the keys written and the (managed) keys removed.
|
|
20
21
|
*/
|
|
21
|
-
export const mergeEnvFile = (path, updates) => {
|
|
22
|
+
export const mergeEnvFile = (path, updates, options = {}) => {
|
|
22
23
|
const original = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
23
|
-
const { content, written } = mergeEnvContent(original, updates);
|
|
24
|
+
const { content, written, removed } = mergeEnvContent(original, updates, options);
|
|
24
25
|
writeFileSync(path, content);
|
|
25
|
-
return { written };
|
|
26
|
+
return { written, removed };
|
|
26
27
|
};
|
|
27
28
|
/**
|
|
28
29
|
* Pure core of {@link mergeEnvFile}: takes the current file content and the updates, and
|
|
29
|
-
* returns the new content plus which keys were written. Kept side-effect-free so
|
|
30
|
-
* unit-tested without touching the filesystem.
|
|
30
|
+
* returns the new content plus which keys were written / removed. Kept side-effect-free so
|
|
31
|
+
* it can be unit-tested without touching the filesystem.
|
|
31
32
|
*/
|
|
32
|
-
export const mergeEnvContent = (original, updates) => {
|
|
33
|
+
export const mergeEnvContent = (original, updates, options = {}) => {
|
|
33
34
|
const keys = Object.keys(updates);
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
// Owned keys the current pull did not produce: stale Neon-managed vars to prune. Anything
|
|
36
|
+
// not in `managedKeys` is always kept, so a user's own lines are never removed.
|
|
37
|
+
const stale = new Set([...(options.managedKeys ?? [])].filter((key) => !(key in updates)));
|
|
38
|
+
if (keys.length === 0 && stale.size === 0) {
|
|
39
|
+
return { content: original, written: [], removed: [] };
|
|
40
|
+
}
|
|
36
41
|
const remaining = new Set(keys);
|
|
42
|
+
const removed = [];
|
|
37
43
|
const lines = original === '' ? [] : original.split('\n');
|
|
38
|
-
//
|
|
39
|
-
// comments are preserved.
|
|
40
|
-
const updatedLines =
|
|
44
|
+
// Walk the file: drop stale owned lines, update existing keys in place (so their position
|
|
45
|
+
// and any surrounding comments are preserved), and pass everything else through untouched.
|
|
46
|
+
const updatedLines = [];
|
|
47
|
+
for (const line of lines) {
|
|
41
48
|
const key = parseKey(line);
|
|
49
|
+
if (key !== null && stale.has(key)) {
|
|
50
|
+
removed.push(key);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
42
53
|
if (key !== null && remaining.has(key)) {
|
|
43
54
|
remaining.delete(key);
|
|
44
|
-
|
|
55
|
+
updatedLines.push(formatLine(key, updates[key]));
|
|
56
|
+
continue;
|
|
45
57
|
}
|
|
46
|
-
|
|
47
|
-
}
|
|
58
|
+
updatedLines.push(line);
|
|
59
|
+
}
|
|
48
60
|
// Append keys that weren't already present, in the order they were given.
|
|
49
61
|
const appended = keys
|
|
50
62
|
.filter((key) => remaining.has(key))
|
|
@@ -55,6 +67,7 @@ export const mergeEnvContent = (original, updates) => {
|
|
|
55
67
|
// A dotenv file ends with a trailing newline.
|
|
56
68
|
content: content === '' ? '' : `${content}\n`,
|
|
57
69
|
written: keys,
|
|
70
|
+
removed,
|
|
58
71
|
};
|
|
59
72
|
};
|
|
60
73
|
/**
|
package/functions_api.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { ContentType } from '@neondatabase/api-client';
|
|
2
2
|
const functionsPath = (projectId, branchId) => `/projects/${encodeURIComponent(projectId)}/branches/${encodeURIComponent(branchId)}/functions`;
|
|
3
|
-
export const listFunctions = async (apiClient, projectId, branchId) => {
|
|
3
|
+
export const listFunctions = async (apiClient, projectId, branchId, { cursor, limit } = {}) => {
|
|
4
4
|
const { data } = await apiClient.request({
|
|
5
5
|
path: functionsPath(projectId, branchId),
|
|
6
6
|
method: 'GET',
|
|
7
|
+
query: { cursor, limit },
|
|
7
8
|
secure: true,
|
|
8
9
|
format: 'json',
|
|
9
10
|
});
|
|
10
|
-
return data.functions;
|
|
11
|
+
return { functions: data.functions ?? [], next: data.pagination?.next };
|
|
11
12
|
};
|
|
12
13
|
export const getFunction = async (apiClient, projectId, branchId, slug) => {
|
|
13
14
|
const { data } = await apiClient.request({
|
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.26.
|
|
8
|
+
"version": "2.26.3",
|
|
9
9
|
"description": "CLI tool for NeonDB Cloud management",
|
|
10
10
|
"main": "index.js",
|
|
11
11
|
"author": "NeonDB",
|
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
"dependencies": {
|
|
60
60
|
"@hono/node-server": "2.0.4",
|
|
61
61
|
"@neondatabase/api-client": "2.7.1",
|
|
62
|
-
"@neondatabase/config": "0.7.
|
|
63
|
-
"@neondatabase/config-runtime": "0.7.
|
|
64
|
-
"@neondatabase/env": "0.5.
|
|
62
|
+
"@neondatabase/config": "0.7.2",
|
|
63
|
+
"@neondatabase/config-runtime": "0.7.2",
|
|
64
|
+
"@neondatabase/env": "0.5.2",
|
|
65
65
|
"@segment/analytics-node": "1.3.0",
|
|
66
66
|
"axios": "1.7.2",
|
|
67
67
|
"axios-debug-log": "1.0.0",
|
|
@@ -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.16.
|
|
74
|
+
"neon-init": "0.16.3",
|
|
75
75
|
"open": "10.1.0",
|
|
76
76
|
"openid-client": "6.8.1",
|
|
77
77
|
"pg-protocol": "^1.14.0",
|
package/test_utils/fixtures.js
CHANGED
|
@@ -41,7 +41,7 @@ export const test = originalTest.extend({
|
|
|
41
41
|
'--api-host',
|
|
42
42
|
`http://localhost:${server.address().port}`,
|
|
43
43
|
'--output',
|
|
44
|
-
options.outputTable ? 'table' : 'yaml',
|
|
44
|
+
options.output ?? (options.outputTable ? 'table' : 'yaml'),
|
|
45
45
|
'--api-key',
|
|
46
46
|
'test-key',
|
|
47
47
|
'--no-analytics',
|
package/utils/bootstrap.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import axios, { isAxiosError } from 'axios';
|
|
2
|
+
import { gunzipSync } from 'fflate';
|
|
2
3
|
import YAML from 'yaml';
|
|
3
4
|
import { log } from '../log.js';
|
|
4
|
-
/**
|
|
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
|
+
*/
|
|
5
11
|
export const FALLBACK_TEMPLATES = [
|
|
6
12
|
{
|
|
7
13
|
id: 'hono',
|
|
@@ -15,25 +21,44 @@ export const FALLBACK_TEMPLATES = [
|
|
|
15
21
|
subdir: 'with-hono',
|
|
16
22
|
},
|
|
17
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
|
+
},
|
|
18
48
|
];
|
|
19
49
|
export const templateIds = (templates) => templates.map((t) => t.id).join(', ');
|
|
20
50
|
export const findTemplate = (templates, id) => templates.find((t) => t.id === id);
|
|
21
|
-
// Hosts are overridable so the e2e tests can point the downloader at a local
|
|
22
|
-
// server (the same trick `--api-host` uses to redirect the Neon API in tests).
|
|
23
|
-
// The defaults hit public GitHub; copying a public template needs no auth.
|
|
24
|
-
const githubApiBase = () => process.env.NEON_BOOTSTRAP_GITHUB_API ?? 'https://api.github.com';
|
|
25
|
-
const githubRawBase = () => process.env.NEON_BOOTSTRAP_GITHUB_RAW ?? 'https://raw.githubusercontent.com';
|
|
26
51
|
const githubToken = () => process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? '';
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
...(githubToken() ? { Authorization: `Bearer ${githubToken()}` } : {}),
|
|
32
|
-
});
|
|
33
|
-
const rawHeaders = () => ({
|
|
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 = () => ({
|
|
34
56
|
'User-Agent': 'neonctl',
|
|
35
57
|
...(githubToken() ? { Authorization: `Bearer ${githubToken()}` } : {}),
|
|
36
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';
|
|
37
62
|
const isRecord = (value) => typeof value === 'object' && value !== null;
|
|
38
63
|
/**
|
|
39
64
|
* Normalize a manifest entry's `services` into a clean string list. Tolerant by
|
|
@@ -51,8 +76,18 @@ const parseServices = (value) => {
|
|
|
51
76
|
// ---------------------------------------------------------------------------
|
|
52
77
|
// Remote template manifest
|
|
53
78
|
// ---------------------------------------------------------------------------
|
|
54
|
-
|
|
55
|
-
|
|
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
|
+
};
|
|
56
91
|
export const parseManifest = (text) => {
|
|
57
92
|
const data = YAML.parse(text);
|
|
58
93
|
if (!isRecord(data) || !Array.isArray(data.templates)) {
|
|
@@ -90,154 +125,240 @@ export const parseManifest = (text) => {
|
|
|
90
125
|
return templates;
|
|
91
126
|
};
|
|
92
127
|
/**
|
|
93
|
-
* Fetch the template manifest
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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.
|
|
96
132
|
*/
|
|
97
133
|
export const fetchTemplates = async () => {
|
|
98
|
-
const url
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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));
|
|
109
149
|
}
|
|
110
|
-
return templates;
|
|
111
|
-
}
|
|
112
|
-
catch (err) {
|
|
113
|
-
log.debug('bootstrap: failed to fetch manifest from %s: %s — using built-in defaults.', url, err instanceof Error ? err.message : String(err));
|
|
114
|
-
return FALLBACK_TEMPLATES;
|
|
115
150
|
}
|
|
151
|
+
log.debug('bootstrap: all manifest sources exhausted; using built-in defaults.');
|
|
152
|
+
return FALLBACK_TEMPLATES;
|
|
116
153
|
};
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
typeof commit.tree.sha !== 'string') {
|
|
126
|
-
throw malformed('the template tree');
|
|
127
|
-
}
|
|
128
|
-
return { commitSha: data.sha, treeSha: commit.tree.sha };
|
|
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);
|
|
129
162
|
};
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
163
|
+
const readTarOctal = (buf, offset, length) => {
|
|
164
|
+
const text = readTarString(buf, offset, length).trim();
|
|
165
|
+
if (text === '') {
|
|
166
|
+
return 0;
|
|
133
167
|
}
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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;
|
|
141
175
|
}
|
|
142
176
|
}
|
|
143
|
-
return
|
|
177
|
+
return true;
|
|
144
178
|
};
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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;
|
|
150
196
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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);
|
|
154
201
|
}
|
|
202
|
+
pos += len;
|
|
155
203
|
}
|
|
156
|
-
return
|
|
204
|
+
return records;
|
|
157
205
|
};
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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) });
|
|
165
272
|
}
|
|
273
|
+
return entries;
|
|
166
274
|
};
|
|
167
275
|
/**
|
|
168
|
-
* Map
|
|
169
|
-
* `subdir/` prefix stripped from each
|
|
170
|
-
*
|
|
171
|
-
*
|
|
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.
|
|
172
280
|
*/
|
|
173
|
-
export const
|
|
174
|
-
const prefix = `${subdir.replace(
|
|
175
|
-
const
|
|
176
|
-
for (const
|
|
177
|
-
|
|
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)) {
|
|
178
293
|
continue;
|
|
179
294
|
}
|
|
180
|
-
|
|
295
|
+
const path = repoPath.slice(prefix.length);
|
|
296
|
+
if (path === '') {
|
|
181
297
|
continue;
|
|
182
298
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
entries.push({ kind: 'symlink', path, repoPath: node.path });
|
|
299
|
+
if (entry.type === '2') {
|
|
300
|
+
files.push({ kind: 'symlink', path, target: entry.linkname });
|
|
186
301
|
}
|
|
187
|
-
else {
|
|
188
|
-
|
|
302
|
+
else if (entry.type === '0' || entry.type === '7') {
|
|
303
|
+
files.push({
|
|
189
304
|
kind: 'file',
|
|
190
305
|
path,
|
|
191
|
-
|
|
192
|
-
executable:
|
|
306
|
+
bytes: entry.data,
|
|
307
|
+
executable: (entry.mode & 0o111) !== 0,
|
|
193
308
|
});
|
|
194
309
|
}
|
|
310
|
+
// Directories ('5') and any other node types are intentionally skipped.
|
|
195
311
|
}
|
|
196
|
-
return
|
|
312
|
+
return files;
|
|
197
313
|
};
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
throw new Error(`GitHub returned a truncated file tree for ${owner}/${repo}; cannot reliably copy template "${template.id}".`);
|
|
212
|
-
}
|
|
213
|
-
const entries = selectSubtreeEntries(tree, subdir);
|
|
214
|
-
if (entries.length === 0) {
|
|
215
|
-
throw new Error(`Template subdirectory "${subdir}" was not found in ${owner}/${repo}@${ref}.`);
|
|
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
|
+
}
|
|
216
327
|
}
|
|
217
|
-
|
|
218
|
-
return { commitSha: commit.commitSha, entries };
|
|
328
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
219
329
|
};
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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;
|
|
224
339
|
try {
|
|
225
340
|
const res = await axios.get(url, {
|
|
226
341
|
responseType: 'arraybuffer',
|
|
227
|
-
headers:
|
|
342
|
+
headers: downloadHeaders(),
|
|
343
|
+
timeout: 30000,
|
|
228
344
|
});
|
|
229
|
-
|
|
345
|
+
gzipped = Buffer.from(res.data);
|
|
230
346
|
}
|
|
231
347
|
catch (err) {
|
|
232
348
|
throw friendlyGithubError(err, url);
|
|
233
349
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
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;
|
|
243
364
|
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { log } from '../log.js';
|
|
3
|
+
/**
|
|
4
|
+
* Print a one-line "this command is targeting <branch>" notice to **stderr** so
|
|
5
|
+
* the user can sanity-check they're acting on the branch they think they are —
|
|
6
|
+
* before a `status` / `plan` / `apply` / `env pull` does its work. This is the
|
|
7
|
+
* cheap guardrail that catches "I planned against the wrong branch" / "I pulled
|
|
8
|
+
* env from the wrong branch" before it bites.
|
|
9
|
+
*
|
|
10
|
+
* - Skipped for machine-readable output (`--output json|yaml`) so it never has
|
|
11
|
+
* to be reasoned about by a script; it's stderr-only regardless, keeping
|
|
12
|
+
* `--output table` stdout clean for piping too.
|
|
13
|
+
* - `verb` is the leading phrase, e.g. `'Planning against branch'` →
|
|
14
|
+
* `→ Planning against branch main (br-…)`.
|
|
15
|
+
*/
|
|
16
|
+
export const announceTargetBranch = (props, branch, verb) => {
|
|
17
|
+
if (props.output === 'json' || props.output === 'yaml') {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const suffix = branch.usedDefault ? chalk.dim(' · project default') : '';
|
|
21
|
+
log.info('%s %s %s %s%s', chalk.dim('→'), verb, chalk.cyan.bold(branch.branchName), chalk.dim(`(${branch.branchId})`), suffix);
|
|
22
|
+
};
|
package/utils/enrichers.js
CHANGED
|
@@ -40,6 +40,45 @@ export const branchIdFromProps = async (props) => {
|
|
|
40
40
|
props.branchId = await getBranchIdFromProps(props);
|
|
41
41
|
return props.branchId;
|
|
42
42
|
};
|
|
43
|
+
export const resolveBranchRef = async (props) => {
|
|
44
|
+
const branch = 'branch' in props && typeof props.branch === 'string'
|
|
45
|
+
? props.branch
|
|
46
|
+
: props.id;
|
|
47
|
+
const { data } = await props.apiClient.listProjectBranches({
|
|
48
|
+
projectId: props.projectId,
|
|
49
|
+
});
|
|
50
|
+
const branches = data.branches;
|
|
51
|
+
if (branch) {
|
|
52
|
+
const ref = branch.toString();
|
|
53
|
+
const found = looksLikeBranchId(ref)
|
|
54
|
+
? branches.find((b) => b.id === ref)
|
|
55
|
+
: branches.find((b) => b.name === ref);
|
|
56
|
+
if (found) {
|
|
57
|
+
return {
|
|
58
|
+
branchId: found.id,
|
|
59
|
+
branchName: found.name ?? found.id,
|
|
60
|
+
usedDefault: false,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
// A `br-…` id absent from the listing is still usable as an id (trust it like
|
|
64
|
+
// branchIdResolve does); only an unresolved *name* is a genuine error.
|
|
65
|
+
if (looksLikeBranchId(ref)) {
|
|
66
|
+
return { branchId: ref, branchName: ref, usedDefault: false };
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`Branch ${ref} not found.\nAvailable branches: ${branches
|
|
69
|
+
.map((b) => b.name)
|
|
70
|
+
.join(', ')}`);
|
|
71
|
+
}
|
|
72
|
+
const defaultBranch = branches.find((b) => b.default);
|
|
73
|
+
if (!defaultBranch) {
|
|
74
|
+
throw new Error('No default branch found');
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
branchId: defaultBranch.id,
|
|
78
|
+
branchName: defaultBranch.name ?? defaultBranch.id,
|
|
79
|
+
usedDefault: true,
|
|
80
|
+
};
|
|
81
|
+
};
|
|
43
82
|
export const resolveSingleDatabase = async (props) => {
|
|
44
83
|
const { data } = await props.apiClient.listProjectBranchDatabases(props.projectId, props.branchId);
|
|
45
84
|
const databases = data.databases;
|