neonctl 2.24.1 → 2.25.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/README.md +127 -42
- package/commands/auth.js +9 -0
- package/commands/bootstrap.js +603 -0
- package/commands/branches.js +6 -4
- package/commands/bucket.js +118 -5
- package/commands/checkout.js +64 -20
- package/commands/config.js +144 -11
- package/commands/deploy.js +2 -1
- package/commands/dev.js +13 -57
- package/commands/env.js +10 -2
- package/commands/functions.js +53 -5
- package/commands/index.js +2 -0
- package/commands/link.js +441 -108
- package/commands/projects.js +2 -2
- package/commands/set_context.js +5 -1
- package/config_format.js +8 -2
- package/context.js +33 -5
- package/dev/env.js +47 -6
- package/dev/functions.js +2 -4
- package/dev/runtime.js +2 -2
- package/index.js +1 -0
- package/package.json +5 -5
- package/storage_api.js +34 -0
- package/utils/bootstrap.js +243 -0
- package/utils/branch_picker.js +16 -1
- package/utils/esbuild.js +11 -2
package/commands/dev.js
CHANGED
|
@@ -56,6 +56,7 @@ const runSingleSource = async (props) => {
|
|
|
56
56
|
...(props.projectId ? { projectId: props.projectId } : {}),
|
|
57
57
|
...(branchId ? { branchId } : {}),
|
|
58
58
|
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
59
|
+
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
59
60
|
});
|
|
60
61
|
const unit = {
|
|
61
62
|
slug: null,
|
|
@@ -93,6 +94,7 @@ const runFromConfig = async (props) => {
|
|
|
93
94
|
...(props.projectId ? { projectId: props.projectId } : {}),
|
|
94
95
|
...(branchId ? { branchId } : {}),
|
|
95
96
|
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
97
|
+
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
96
98
|
});
|
|
97
99
|
const units = planFunctionsToUnits(functions, neonEnv, DEFAULT_PORT_BASE);
|
|
98
100
|
// Re-derive the units from neon.ts on demand so the config watcher can hot-add/remove
|
|
@@ -114,7 +116,7 @@ const runFromConfig = async (props) => {
|
|
|
114
116
|
* Map a list of {@link PlannedFunction}s to {@link ServedUnit}s, coordinating the search
|
|
115
117
|
* base across them so search-mode functions don't all probe the same starting port.
|
|
116
118
|
*
|
|
117
|
-
* Each search-mode (no `dev.port
|
|
119
|
+
* Each search-mode (no `dev.port`) function gets a distinct base starting at
|
|
118
120
|
* `searchBase`; the runtime still walks upward from its base, so an occupied base
|
|
119
121
|
* self-resolves and this never fails — the offset just makes startup deterministic.
|
|
120
122
|
*/
|
|
@@ -122,7 +124,7 @@ const planFunctionsToUnits = (functions, neonEnv, searchBase) => {
|
|
|
122
124
|
let searchOffset = 0;
|
|
123
125
|
return functions.map((fn) => {
|
|
124
126
|
const base = searchBase + searchOffset;
|
|
125
|
-
if (
|
|
127
|
+
if (fn.port === undefined)
|
|
126
128
|
searchOffset += 1;
|
|
127
129
|
return plannedToUnit(fn, neonEnv, base);
|
|
128
130
|
});
|
|
@@ -160,19 +162,14 @@ const portFromProps = (port) => {
|
|
|
160
162
|
};
|
|
161
163
|
/**
|
|
162
164
|
* Translate a {@link PlannedFunction} into a {@link ServedUnit}. Port rules:
|
|
163
|
-
* - portless: portless assigns the port and injects PORT, which the runtime honors — so
|
|
164
|
-
* we set no port env (`inherit`) and `dev.port` is ignored. Wrapped with
|
|
165
|
-
* `portless <slug>` for a stable `slug.localhost` URL.
|
|
166
165
|
* - explicit `dev.port`: bind exactly, fail if taken.
|
|
167
166
|
* - no `dev.port`: search for a free port (base coordinated by the caller).
|
|
168
167
|
* Per-function neon.ts env layers over the shared branch env.
|
|
169
168
|
*/
|
|
170
169
|
const plannedToUnit = (fn, branchEnv, searchBase) => {
|
|
171
|
-
const port = fn.
|
|
172
|
-
? { mode: '
|
|
173
|
-
:
|
|
174
|
-
? { mode: 'explicit', port: fn.port }
|
|
175
|
-
: { mode: 'search', from: searchBase };
|
|
170
|
+
const port = fn.port !== undefined
|
|
171
|
+
? { mode: 'explicit', port: fn.port }
|
|
172
|
+
: { mode: 'search', from: searchBase };
|
|
176
173
|
const childEnv = buildChildEnv({ ...branchEnv, ...fn.env }, port);
|
|
177
174
|
return {
|
|
178
175
|
slug: fn.slug,
|
|
@@ -188,10 +185,8 @@ const plannedToUnit = (fn, branchEnv, searchBase) => {
|
|
|
188
185
|
configKey: JSON.stringify({
|
|
189
186
|
source: fn.source,
|
|
190
187
|
port: fn.port ?? null,
|
|
191
|
-
portless: fn.portless,
|
|
192
188
|
env: fn.env,
|
|
193
189
|
}),
|
|
194
|
-
...(fn.portless ? { portless: { slug: fn.slug } } : {}),
|
|
195
190
|
};
|
|
196
191
|
};
|
|
197
192
|
/**
|
|
@@ -210,7 +205,6 @@ const buildChildEnv = (neonEnv, port) => {
|
|
|
210
205
|
else if (port.mode === 'search') {
|
|
211
206
|
env.NEON_DEV_PORT_BASE = String(port.from);
|
|
212
207
|
}
|
|
213
|
-
// 'inherit': set neither, so an injected PORT (portless) drives the runtime.
|
|
214
208
|
return env;
|
|
215
209
|
};
|
|
216
210
|
const READY_PATTERN = /neon-dev:ready (\d+)/;
|
|
@@ -219,20 +213,16 @@ const READY_PATTERN = /neon-dev:ready (\d+)/;
|
|
|
219
213
|
* its inputs for hot reload, and tear everything down cleanly on shutdown. Units are
|
|
220
214
|
* independent — one crashing or failing to start does not stop the others (it is shown
|
|
221
215
|
* as errored and recovered on the next edit). A single SIGINT/SIGTERM shuts all of them
|
|
222
|
-
* down, tree-killing each child so no descendant
|
|
223
|
-
* orphaned.
|
|
216
|
+
* down, tree-killing each child so no descendant it spawned is orphaned.
|
|
224
217
|
*
|
|
225
218
|
* In config mode, `reload` lets the supervisor watch `neon.ts` and reconcile the live set
|
|
226
219
|
* of units when it changes: a newly-declared function is hot-added (its own child, watcher,
|
|
227
220
|
* and port) and a removed one is torn down — all without disturbing the functions that
|
|
228
|
-
* stayed the same. A function whose config (env/port/
|
|
221
|
+
* stayed the same. A function whose config (env/port/source) changed is restarted
|
|
229
222
|
* in place; siblings are untouched.
|
|
230
223
|
*/
|
|
231
224
|
const runSupervisor = async (units, options = {}) => {
|
|
232
225
|
const { reload, envNote } = options;
|
|
233
|
-
if (hasPortlessUnit(units)) {
|
|
234
|
-
assertPortlessAvailable();
|
|
235
|
-
}
|
|
236
226
|
const runtimePath = resolveRuntimePath();
|
|
237
227
|
let shuttingDown = false;
|
|
238
228
|
const running = units.map(makeRunningUnit);
|
|
@@ -378,7 +368,7 @@ const makeRunningUnit = (unit) => ({
|
|
|
378
368
|
* Pure slug-keyed diff of the live units against the freshly-resolved desired set:
|
|
379
369
|
* - a slug present now but not before → **add** (new child + watcher + port),
|
|
380
370
|
* - a slug gone from neon.ts → **remove** (torn down),
|
|
381
|
-
* - a slug whose config (source/port/
|
|
371
|
+
* - a slug whose config (source/port/env) changed → **restart** in place,
|
|
382
372
|
* - an unchanged slug → left out of the plan entirely (never touched).
|
|
383
373
|
* Functions that stayed the same never die, so an edit that only adds a function is
|
|
384
374
|
* non-disruptive. `desired === null` (neon.ts deleted) is treated as "no functions".
|
|
@@ -434,8 +424,6 @@ const reconcileOnce = async (running, replan, ops) => {
|
|
|
434
424
|
}
|
|
435
425
|
if (ops.isShuttingDown())
|
|
436
426
|
return;
|
|
437
|
-
if (hasPortlessUnit(desired ?? []))
|
|
438
|
-
assertPortlessAvailable();
|
|
439
427
|
const plan = diffUnits(running, desired);
|
|
440
428
|
for (const r of plan.remove) {
|
|
441
429
|
logUnit(r.unit, chalk.dim('removed from neon.ts, stopping…'));
|
|
@@ -477,50 +465,18 @@ const nextSearchBase = (running) => {
|
|
|
477
465
|
}
|
|
478
466
|
return max + 1;
|
|
479
467
|
};
|
|
480
|
-
const hasPortlessUnit = (units) => units.some((u) => u.portless !== undefined);
|
|
481
468
|
/**
|
|
482
|
-
* Spawn the child for a unit
|
|
483
|
-
* <runtime> <bundle>`: portless assigns a port, injects it as PORT (which the runtime
|
|
484
|
-
* honors), and exposes the server at `slug.localhost`. A plain unit runs the bundled
|
|
485
|
-
* output directly under `node`.
|
|
469
|
+
* Spawn the child for a unit: the bundled output run directly under `node`.
|
|
486
470
|
*
|
|
487
|
-
* Spawned detached (own process group) so killTree can reap the whole group
|
|
488
|
-
* for the portless case, where the tree is portless -> node runtime.
|
|
471
|
+
* Spawned detached (own process group) so killTree can reap the whole group.
|
|
489
472
|
*/
|
|
490
473
|
const spawnChild = (unit, runtimePath, bundlePath) => {
|
|
491
|
-
if (unit.portless) {
|
|
492
|
-
return spawn('portless', [unit.portless.slug, process.execPath, runtimePath, bundlePath], {
|
|
493
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
494
|
-
env: unit.childEnv,
|
|
495
|
-
detached: true,
|
|
496
|
-
});
|
|
497
|
-
}
|
|
498
474
|
return spawn(process.execPath, [runtimePath, bundlePath], {
|
|
499
475
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
500
476
|
env: unit.childEnv,
|
|
501
477
|
detached: true,
|
|
502
478
|
});
|
|
503
479
|
};
|
|
504
|
-
/** Fail early with an actionable message if a portless unit is requested but the binary is missing. */
|
|
505
|
-
const assertPortlessAvailable = () => {
|
|
506
|
-
const result = spawnSyncCheck('portless');
|
|
507
|
-
if (!result) {
|
|
508
|
-
throw new Error('A function sets `dev.portless: true`, but the `portless` command was not ' +
|
|
509
|
-
'found on your PATH. Install it globally (e.g. `npm i -g portless`) or ' +
|
|
510
|
-
'remove `dev.portless` from the function in neon.ts.');
|
|
511
|
-
}
|
|
512
|
-
};
|
|
513
|
-
const spawnSyncCheck = (bin) => {
|
|
514
|
-
try {
|
|
515
|
-
// Synchronous, no-side-effect probe: `which`/`where` resolves the binary.
|
|
516
|
-
const probe = process.platform === 'win32' ? 'where' : 'which';
|
|
517
|
-
const { status } = spawnSync(probe, [bin]);
|
|
518
|
-
return status === 0;
|
|
519
|
-
}
|
|
520
|
-
catch {
|
|
521
|
-
return false;
|
|
522
|
-
}
|
|
523
|
-
};
|
|
524
480
|
const writeBundle = async (source, bundleDir) => {
|
|
525
481
|
const files = await bundleEntry(source);
|
|
526
482
|
mkdirSync(bundleDir, { recursive: true });
|
|
@@ -689,7 +645,7 @@ const startDirectoryWatcher = async (chokidar, source, restart) => {
|
|
|
689
645
|
/**
|
|
690
646
|
* Terminate a child and every descendant it spawned. The child is started `detached`, so
|
|
691
647
|
* on POSIX it leads its own process group and a negative-PID signal reaps the group
|
|
692
|
-
* (covering
|
|
648
|
+
* (covering the runtime and anything it spawned). On Windows there are no POSIX groups, so we
|
|
693
649
|
* shell out to `taskkill /T` to kill the tree. Escalates SIGTERM -> SIGKILL after 2s.
|
|
694
650
|
*/
|
|
695
651
|
const killTree = (child) => {
|
package/commands/env.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { NEON_ENV_VAR_KEYS } from '@neondatabase/env';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
3
4
|
import { log } from '../log.js';
|
|
4
5
|
import { resolveNeonEnvVars } from '../dev/env.js';
|
|
5
|
-
import { mergeEnvFile, resolveEnvFilePath } from '../env_file.js';
|
|
6
|
+
import { mergeEnvFile, readEnvFile, resolveEnvFilePath } from '../env_file.js';
|
|
6
7
|
import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
|
|
7
8
|
export const command = 'env';
|
|
8
9
|
export const describe = "Manage a branch's Neon env variables locally";
|
|
@@ -41,6 +42,12 @@ const NEON_VAR_NAMES = Object.values(NEON_ENV_VAR_KEYS).flatMap((group) => Objec
|
|
|
41
42
|
export const pull = async (props) => {
|
|
42
43
|
const cwd = props.cwd ?? process.cwd();
|
|
43
44
|
const branchId = await branchIdFromProps(props);
|
|
45
|
+
// Resolve the target file first and layer its current contents under the resolver's env
|
|
46
|
+
// source. This lets `fetchEnv` reuse one-time secrets that are already on disk — Neon Auth
|
|
47
|
+
// keys and the unified branch credential's `api_token` / `s3_secret_access_key`, which the
|
|
48
|
+
// API returns exactly once — instead of minting a fresh credential on every pull.
|
|
49
|
+
const targetPath = resolveEnvFilePath(cwd, props.file);
|
|
50
|
+
const existingEnv = existsSync(targetPath) ? readEnvFile(targetPath) : {};
|
|
44
51
|
// Reuse `neon dev`'s tiered resolver (neon.ts policy -> plan gate -> fetchEnv, else
|
|
45
52
|
// pullConfig -> fetchEnv). Unlike dev, an unresolved context or failure is surfaced —
|
|
46
53
|
// `env pull` is an explicit action, so it should error rather than write nothing.
|
|
@@ -48,7 +55,9 @@ export const pull = async (props) => {
|
|
|
48
55
|
cwd,
|
|
49
56
|
projectId: props.projectId,
|
|
50
57
|
branchId,
|
|
58
|
+
env: { ...process.env, ...existingEnv },
|
|
51
59
|
...(props.apiKey ? { apiKey: props.apiKey } : {}),
|
|
60
|
+
...(props.apiHost ? { apiHost: props.apiHost } : {}),
|
|
52
61
|
...(props.runtimeApi ? { api: props.runtimeApi } : {}),
|
|
53
62
|
});
|
|
54
63
|
const neonVars = pickNeonVars(vars);
|
|
@@ -57,7 +66,6 @@ export const pull = async (props) => {
|
|
|
57
66
|
'enabled Auth / Data API).');
|
|
58
67
|
return { status: 'empty' };
|
|
59
68
|
}
|
|
60
|
-
const targetPath = resolveEnvFilePath(cwd, props.file);
|
|
61
69
|
const { written } = mergeEnvFile(targetPath, neonVars);
|
|
62
70
|
log.info('Pulled %d Neon variable%s into %s: %s', written.length, written.length === 1 ? '' : 's', targetPath, written.join(', '));
|
|
63
71
|
return { status: 'written', written, file: targetPath };
|
package/commands/functions.js
CHANGED
|
@@ -21,6 +21,23 @@ const DEPLOYMENT_FIELDS = [
|
|
|
21
21
|
'memory_mib',
|
|
22
22
|
'created_at',
|
|
23
23
|
];
|
|
24
|
+
// Deploy emits the resolved deployment plus the function's invocation_url, so a
|
|
25
|
+
// successful `functions deploy` tells the user exactly where to call the function.
|
|
26
|
+
const DEPLOY_RESULT_FIELDS = [
|
|
27
|
+
'id',
|
|
28
|
+
'status',
|
|
29
|
+
'invocation_url',
|
|
30
|
+
'runtime',
|
|
31
|
+
'memory_mib',
|
|
32
|
+
'created_at',
|
|
33
|
+
];
|
|
34
|
+
// In table mode a failed build's reason gets its own "deployment error"
|
|
35
|
+
// section after the deployment table; json/yaml carry the raw `error` field.
|
|
36
|
+
const writeDeploymentErrorSection = (out, dep) => {
|
|
37
|
+
if (dep.status === 'failed' && dep.error) {
|
|
38
|
+
out.write({ reason: dep.error }, { fields: ['reason'], title: 'deployment error' });
|
|
39
|
+
}
|
|
40
|
+
};
|
|
24
41
|
const SLUG_PATTERN = /^[a-z0-9]{1,20}$/;
|
|
25
42
|
const SLUG_HELP = 'Use 1-20 lowercase letters and digits (no hyphens or other characters).';
|
|
26
43
|
// Overridable so tests can poll fast; defaults to 2s in real use.
|
|
@@ -77,10 +94,19 @@ export const builder = (argv) => argv
|
|
|
77
94
|
},
|
|
78
95
|
}), (args) => deploy(args))
|
|
79
96
|
.command('list', 'List functions on the branch', (yargs) => yargs, (args) => list(args))
|
|
80
|
-
.command('get <slug>', "Show a function's details", (yargs) => yargs
|
|
97
|
+
.command('get <slug>', "Show a function's details", (yargs) => yargs
|
|
98
|
+
.positional('slug', {
|
|
81
99
|
describe: 'Function slug',
|
|
82
100
|
type: 'string',
|
|
83
101
|
demandOption: true,
|
|
102
|
+
})
|
|
103
|
+
.options({
|
|
104
|
+
'list-env-variables': {
|
|
105
|
+
describe: 'List the environment variable names of the active deployment',
|
|
106
|
+
type: 'boolean',
|
|
107
|
+
alias: 'E',
|
|
108
|
+
default: false,
|
|
109
|
+
},
|
|
84
110
|
}), (args) => get(args))
|
|
85
111
|
.command('delete <slug>', 'Delete a function on the branch', (yargs) => yargs.positional('slug', {
|
|
86
112
|
describe: 'Function slug',
|
|
@@ -104,6 +130,15 @@ const parseEnv = (entries) => {
|
|
|
104
130
|
return JSON.stringify(map);
|
|
105
131
|
};
|
|
106
132
|
const statusHint = (slug, projectId, branchId) => `Check status with: neonctl functions get ${slug} --project-id ${projectId} --branch ${branchId}`;
|
|
133
|
+
// Emit the resolved deployment together with the function's invocation_url, so the
|
|
134
|
+
// deploy output shows where the function is reachable (not just the deployment id).
|
|
135
|
+
const emitDeployResult = (props, deployment, fn) => {
|
|
136
|
+
const out = writer(props).write({ ...deployment, invocation_url: fn?.invocation_url }, { fields: DEPLOY_RESULT_FIELDS });
|
|
137
|
+
if (props.output !== 'json' && props.output !== 'yaml') {
|
|
138
|
+
writeDeploymentErrorSection(out, deployment);
|
|
139
|
+
}
|
|
140
|
+
out.end();
|
|
141
|
+
};
|
|
107
142
|
// A poll error worth retrying: a network error (no HTTP response), a 5xx, or a
|
|
108
143
|
// 404 from eventual consistency. Anything else (e.g. 401/403) is surfaced.
|
|
109
144
|
const isTransient = (err) => isAxiosError(err) &&
|
|
@@ -165,6 +200,9 @@ const deploy = async (props) => {
|
|
|
165
200
|
// any version if there was none). --no-wait stops there; --wait stops at a
|
|
166
201
|
// terminal status. Bounded by POLL_TIMEOUT_MS so it never hangs.
|
|
167
202
|
let resolved;
|
|
203
|
+
// The function carries the invocation_url; keep the whole record (not just its
|
|
204
|
+
// active_deployment) so we can surface that URL on success.
|
|
205
|
+
let resolvedFn;
|
|
168
206
|
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
169
207
|
try {
|
|
170
208
|
while (!interrupted && Date.now() < deadline) {
|
|
@@ -173,18 +211,20 @@ const deploy = async (props) => {
|
|
|
173
211
|
break;
|
|
174
212
|
// The deploy already succeeded server-side; tolerate transient poll
|
|
175
213
|
// failures and retry on the next interval. Surface anything else.
|
|
176
|
-
let
|
|
214
|
+
let fn;
|
|
177
215
|
try {
|
|
178
|
-
|
|
216
|
+
fn = await getFunction(props.apiClient, props.projectId, branchId, props.slug);
|
|
179
217
|
}
|
|
180
218
|
catch (err) {
|
|
181
219
|
if (isTransient(err))
|
|
182
220
|
continue;
|
|
183
221
|
throw err;
|
|
184
222
|
}
|
|
223
|
+
const dep = fn.active_deployment;
|
|
185
224
|
const isNew = dep !== undefined && (before === undefined || dep.id > before);
|
|
186
225
|
if (isNew && dep) {
|
|
187
226
|
resolved = dep;
|
|
227
|
+
resolvedFn = fn;
|
|
188
228
|
if (!props.wait)
|
|
189
229
|
break;
|
|
190
230
|
if (dep.status === 'completed' || dep.status === 'failed')
|
|
@@ -199,14 +239,14 @@ const deploy = async (props) => {
|
|
|
199
239
|
if (interrupted) {
|
|
200
240
|
log.info(statusHint(props.slug, props.projectId, branchId));
|
|
201
241
|
if (resolved)
|
|
202
|
-
|
|
242
|
+
emitDeployResult(props, resolved, resolvedFn);
|
|
203
243
|
return;
|
|
204
244
|
}
|
|
205
245
|
if (resolved === undefined) {
|
|
206
246
|
log.info(statusHint(props.slug, props.projectId, branchId));
|
|
207
247
|
throw new Error(`Timed out waiting for the deployment of ${props.slug} to start. It may still be in progress.`);
|
|
208
248
|
}
|
|
209
|
-
|
|
249
|
+
emitDeployResult(props, resolved, resolvedFn);
|
|
210
250
|
if (!props.wait) {
|
|
211
251
|
log.info(statusHint(props.slug, props.projectId, branchId));
|
|
212
252
|
return;
|
|
@@ -238,6 +278,14 @@ const get = async (props) => {
|
|
|
238
278
|
fields: DEPLOYMENT_FIELDS,
|
|
239
279
|
title: 'active deployment',
|
|
240
280
|
});
|
|
281
|
+
writeDeploymentErrorSection(out, fn.active_deployment);
|
|
282
|
+
}
|
|
283
|
+
if (props.listEnvVariables) {
|
|
284
|
+
out.write((fn.active_deployment?.environment ?? []).map((name) => ({ name })), {
|
|
285
|
+
fields: ['name'],
|
|
286
|
+
title: 'environment',
|
|
287
|
+
emptyMessage: 'No environment variables on the active deployment.',
|
|
288
|
+
});
|
|
241
289
|
}
|
|
242
290
|
out.end();
|
|
243
291
|
};
|
package/commands/index.js
CHANGED
|
@@ -22,6 +22,7 @@ import * as config from './config.js';
|
|
|
22
22
|
import * as deploy from './deploy.js';
|
|
23
23
|
import * as env from './env.js';
|
|
24
24
|
import * as bucket from './bucket.js';
|
|
25
|
+
import * as bootstrap from './bootstrap.js';
|
|
25
26
|
export default [
|
|
26
27
|
auth,
|
|
27
28
|
users,
|
|
@@ -47,4 +48,5 @@ export default [
|
|
|
47
48
|
deploy,
|
|
48
49
|
env,
|
|
49
50
|
bucket,
|
|
51
|
+
bootstrap,
|
|
50
52
|
];
|