@grayspar/cli 0.0.0-stage → 0.0.1
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/LICENSE +202 -0
- package/README.md +76 -2
- package/dist/commands/access.js +103 -0
- package/dist/commands/deploy/archive.js +67 -0
- package/dist/commands/deploy/deploy-flags.js +54 -0
- package/dist/commands/deploy/deploy.js +366 -0
- package/dist/commands/deploy/env-files.js +63 -0
- package/dist/commands/deploy/git-state.js +22 -0
- package/dist/commands/deploy/project-name.js +13 -0
- package/dist/commands/deploy/variables-to-send.js +69 -0
- package/dist/commands/env.js +70 -0
- package/dist/commands/link.js +27 -0
- package/dist/commands/login/challenge.js +5 -0
- package/dist/commands/login/login.js +56 -0
- package/dist/commands/login/open-browser.js +15 -0
- package/dist/commands/login/sign-in-callback.js +36 -0
- package/dist/commands/logout.js +30 -0
- package/dist/commands/logs.js +84 -0
- package/dist/commands/open-path.js +45 -0
- package/dist/commands/project.js +22 -0
- package/dist/commands/restart.js +19 -0
- package/dist/commands/service.js +72 -0
- package/dist/commands/status.js +15 -0
- package/dist/commands/volume.js +73 -0
- package/dist/commands/whoami.js +16 -0
- package/dist/index.js +53 -0
- package/dist/lib/client.js +45 -0
- package/dist/lib/command-helpers.js +25 -0
- package/dist/lib/credentials.js +38 -0
- package/dist/lib/errors.js +12 -0
- package/dist/lib/follow-deployment.js +99 -0
- package/dist/lib/listening.js +24 -0
- package/dist/lib/output.js +36 -0
- package/dist/lib/project-link.js +45 -0
- package/dist/lib/prompt.js +65 -0
- package/dist/lib/resolve-project.js +56 -0
- package/dist/lib/resolve-service.js +29 -0
- package/dist/lib/resolve-team.js +31 -0
- package/dist/lib/services-table.js +28 -0
- package/dist/lib/ui.js +58 -0
- package/dist/lib/version.js +12 -0
- package/package.json +47 -3
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/* Upload, then send the deploy and ask what the API returns, one question per round so a refused answer is asked
|
|
2
|
+
again at once: which service answers the address, and a path for each new one. */
|
|
3
|
+
import { basename, resolve } from 'node:path';
|
|
4
|
+
import { Command, Option } from 'commander';
|
|
5
|
+
import { dataOrThrow, signedInClient } from '../../lib/client.js';
|
|
6
|
+
import { collectRepeatedValue, commandAction } from '../../lib/command-helpers.js';
|
|
7
|
+
import { CliError } from '../../lib/errors.js';
|
|
8
|
+
import { awaitDeployment, reportFailure } from '../../lib/follow-deployment.js';
|
|
9
|
+
import { portLines } from '../../lib/listening.js';
|
|
10
|
+
import { printNote, startProgress } from '../../lib/output.js';
|
|
11
|
+
import { findLink, writeLink } from '../../lib/project-link.js';
|
|
12
|
+
import { askChoice, askText, isInteractive } from '../../lib/prompt.js';
|
|
13
|
+
import { projectIsNamed, resolveProject, teamProjects } from '../../lib/resolve-project.js';
|
|
14
|
+
import { resolveTeam } from '../../lib/resolve-team.js';
|
|
15
|
+
import { servicesTable } from '../../lib/services-table.js';
|
|
16
|
+
import { color, failure, receipt, table, warning } from '../../lib/ui.js';
|
|
17
|
+
import { archiveOf } from './archive.js';
|
|
18
|
+
import { parseDeployFlags, servicesToSend, } from './deploy-flags.js';
|
|
19
|
+
import { emptyFoundVariables, variablesFor } from './env-files.js';
|
|
20
|
+
import { gitStateOf } from './git-state.js';
|
|
21
|
+
import { projectNameFrom } from './project-name.js';
|
|
22
|
+
import { variablesToSend } from './variables-to-send.js';
|
|
23
|
+
async function deploy(folder, options) {
|
|
24
|
+
const root = resolve(folder);
|
|
25
|
+
const serviceFlags = parseDeployFlags(options.flags);
|
|
26
|
+
const api = await signedInClient();
|
|
27
|
+
const destination = await destinationFor(api, root, options);
|
|
28
|
+
const inspecting = startProgress(options.format, 'Inspecting your project…');
|
|
29
|
+
const uploaded = await uploadSource(api, destination, new Blob([await archiveOf(root)])).finally(inspecting.stop);
|
|
30
|
+
const variables = new Map(await Promise.all(uploaded.services.map(async (service) => [
|
|
31
|
+
service.name,
|
|
32
|
+
options.env
|
|
33
|
+
? await variablesFor(root, service.rootDirectory, uploaded.platformVariables, options.envFile)
|
|
34
|
+
: emptyFoundVariables(),
|
|
35
|
+
])));
|
|
36
|
+
if (options.format === 'text')
|
|
37
|
+
printFoundServices(uploaded, variables);
|
|
38
|
+
const foundNames = uploaded.services.map((service) => service.name);
|
|
39
|
+
const deploying = servicesToSend(foundNames, serviceFlags).flatMap(({ name }) => {
|
|
40
|
+
const known = variables.get(name);
|
|
41
|
+
return known === undefined ? [] : [{ name, foundVariables: known }];
|
|
42
|
+
});
|
|
43
|
+
const toSend = destination.kind === 'project'
|
|
44
|
+
? await variablesToSend(api, destination.project, root, deploying.map((service) => service.name), deploying.map((service) => service.foundVariables), options.preference)
|
|
45
|
+
: Object.fromEntries(deploying.map((service) => [service.name, service.foundVariables.values]));
|
|
46
|
+
const gitState = await gitStateOf(root);
|
|
47
|
+
const { project, deployment } = await submitDeploy(api, destination, options, serviceFlags, foundNames, {
|
|
48
|
+
upload: uploaded.upload,
|
|
49
|
+
variables: toSend,
|
|
50
|
+
...(gitState === null ? {} : { git: gitState }),
|
|
51
|
+
});
|
|
52
|
+
if (destination.kind === 'new' || !destination.isLinked) {
|
|
53
|
+
await writeLink(root, { projectId: project.id });
|
|
54
|
+
}
|
|
55
|
+
if (options.format === 'text') {
|
|
56
|
+
for (const build of deployment.builds) {
|
|
57
|
+
for (const path of build.addedVolumes) {
|
|
58
|
+
process.stdout.write(`${receipt(`Added volume ${path} to ${build.service}`)}\n`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const outcome = await watchDeployment(api, project, deployment.id, options.format);
|
|
63
|
+
if (options.format === 'json') {
|
|
64
|
+
process.stdout.write(`${JSON.stringify({ project: project.name, url: project.url, ...outcome }, null, 2)}\n`);
|
|
65
|
+
if (outcome.status === 'failed')
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
await printOutcome(api, project, outcome, { uploaded, serviceFlags });
|
|
70
|
+
}
|
|
71
|
+
function uploadSource(api, destination, source) {
|
|
72
|
+
const body = { source };
|
|
73
|
+
const bodySerializer = (sent) => {
|
|
74
|
+
const form = new FormData();
|
|
75
|
+
form.append('source', sent.source, 'source.tar.gz');
|
|
76
|
+
return form;
|
|
77
|
+
};
|
|
78
|
+
return dataOrThrow(destination.kind === 'project'
|
|
79
|
+
? api.POST('/api/projects/{id}/uploads', {
|
|
80
|
+
params: { path: { id: destination.project.id } },
|
|
81
|
+
body,
|
|
82
|
+
bodySerializer,
|
|
83
|
+
})
|
|
84
|
+
: api.POST('/api/teams/{teamId}/uploads', {
|
|
85
|
+
params: { path: { teamId: destination.team.id } },
|
|
86
|
+
body,
|
|
87
|
+
bodySerializer,
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
const PATH_POINTER_PATTERN = /^#\/services\/(\d+)\/pathPrefix$/;
|
|
91
|
+
function askedIn(refusal, sent) {
|
|
92
|
+
if (refusal === undefined)
|
|
93
|
+
return null;
|
|
94
|
+
if (refusal.questions !== undefined)
|
|
95
|
+
return { asked: refusal.questions, detail: refusal.detail };
|
|
96
|
+
const paths = (refusal.errors ?? []).flatMap(({ detail, pointer }) => {
|
|
97
|
+
const index = PATH_POINTER_PATTERN.exec(pointer ?? '')?.[1];
|
|
98
|
+
const service = index === undefined ? undefined : sent[Number(index)]?.name;
|
|
99
|
+
return service === undefined ? [] : [{ service, suggestion: null, problem: detail }];
|
|
100
|
+
});
|
|
101
|
+
return paths.length === 0 ? null : { asked: { address: [], paths }, detail: refusal.detail };
|
|
102
|
+
}
|
|
103
|
+
async function sendDeploy(api, destination, body) {
|
|
104
|
+
if (destination.kind === 'project') {
|
|
105
|
+
const answer = await api.POST('/api/projects/{id}/deployments', {
|
|
106
|
+
params: { path: { id: destination.project.id } },
|
|
107
|
+
body,
|
|
108
|
+
});
|
|
109
|
+
const asked = askedIn(answer.error, body.services ?? []);
|
|
110
|
+
if (asked !== null)
|
|
111
|
+
return asked;
|
|
112
|
+
const { deployment } = await dataOrThrow(Promise.resolve(answer));
|
|
113
|
+
return { accepted: { project: destination.project, deployment } };
|
|
114
|
+
}
|
|
115
|
+
const answer = await api.POST('/api/teams/{teamId}/projects', {
|
|
116
|
+
params: { path: { teamId: destination.team.id } },
|
|
117
|
+
body: { ...body, name: destination.name },
|
|
118
|
+
});
|
|
119
|
+
const asked = askedIn(answer.error, body.services ?? []);
|
|
120
|
+
if (asked !== null)
|
|
121
|
+
return asked;
|
|
122
|
+
return { accepted: await dataOrThrow(Promise.resolve(answer)) };
|
|
123
|
+
}
|
|
124
|
+
function refusalFor({ asked, detail }, suggestionsTaken) {
|
|
125
|
+
const action = asked.address.length > 0
|
|
126
|
+
? 'Choose it with --path <name>=/.'
|
|
127
|
+
: suggestionsTaken
|
|
128
|
+
? 'Give the path with --path <name>=<path>.'
|
|
129
|
+
: 'Give each path with --path <name>=<path>, or take the suggested paths with --yes.';
|
|
130
|
+
return new CliError('needs_a_path', `${detail}\n${action}`, '--path');
|
|
131
|
+
}
|
|
132
|
+
function suggestionsFor(askedQuestions) {
|
|
133
|
+
const taken = new Map();
|
|
134
|
+
for (const { service, suggestion, problem } of askedQuestions.asked.paths) {
|
|
135
|
+
if (suggestion === null || problem !== null)
|
|
136
|
+
throw refusalFor(askedQuestions, true);
|
|
137
|
+
taken.set(service, suggestion);
|
|
138
|
+
}
|
|
139
|
+
if (askedQuestions.asked.address.length > 0)
|
|
140
|
+
throw refusalFor(askedQuestions, true);
|
|
141
|
+
return taken;
|
|
142
|
+
}
|
|
143
|
+
async function askFirstQuestion(asked, address) {
|
|
144
|
+
if (asked.address.length > 0) {
|
|
145
|
+
const root = await askChoice(`Select the service that opens at ${address}`, `Opens at ${address}`, asked.address.map((name) => ({ value: name, label: name })), '--path');
|
|
146
|
+
return [root, '/'];
|
|
147
|
+
}
|
|
148
|
+
const [question] = asked.paths;
|
|
149
|
+
if (question === undefined)
|
|
150
|
+
throw new Error('The API asked a question without naming one.');
|
|
151
|
+
if (question.problem !== null)
|
|
152
|
+
process.stdout.write(`${failure(question.problem)}\n`);
|
|
153
|
+
return [
|
|
154
|
+
question.service,
|
|
155
|
+
await askText(`Path for ${question.service}`, question.suggestion, '--path'),
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
async function submitDeploy(api, destination, options, serviceFlags, foundNames, request) {
|
|
159
|
+
const canAsk = options.format === 'text' && isInteractive() && !options.yes;
|
|
160
|
+
let hasAsked = false;
|
|
161
|
+
for (;;) {
|
|
162
|
+
const sent = await sendDeploy(api, destination, {
|
|
163
|
+
...request,
|
|
164
|
+
services: servicesToSend(foundNames, serviceFlags),
|
|
165
|
+
});
|
|
166
|
+
if ('accepted' in sent) {
|
|
167
|
+
if (hasAsked)
|
|
168
|
+
process.stdout.write('\n');
|
|
169
|
+
return sent.accepted;
|
|
170
|
+
}
|
|
171
|
+
if (!options.yes && !canAsk)
|
|
172
|
+
throw refusalFor(sent, false);
|
|
173
|
+
const given = options.yes
|
|
174
|
+
? suggestionsFor(sent)
|
|
175
|
+
: new Map([await askFirstQuestion(sent.asked, addressOf(destination))]);
|
|
176
|
+
hasAsked ||= !options.yes;
|
|
177
|
+
for (const [name, path] of given)
|
|
178
|
+
serviceFlags.paths.set(name, path);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function addressOf(destination) {
|
|
182
|
+
return destination.kind === 'project'
|
|
183
|
+
? new URL(destination.project.url).host
|
|
184
|
+
: "the project's address";
|
|
185
|
+
}
|
|
186
|
+
function printFoundServices(uploaded, variables) {
|
|
187
|
+
const count = uploaded.services.length;
|
|
188
|
+
const rows = uploaded.services.map((service) => [service.name, service.builtWith ?? '…']);
|
|
189
|
+
process.stdout.write(`${color.heading(`Found ${String(count)} ${count === 1 ? 'service' : 'services'}`)}\n\n` +
|
|
190
|
+
`${table(['SERVICE', 'BUILT WITH'], rows)}\n`);
|
|
191
|
+
const leftOut = new Set([...variables.values()].flatMap((service) => Object.entries(service.skippedPlatformVariables).map(([key, file]) => `${key} in ${file} is left out, because Grayspar sets it`)));
|
|
192
|
+
const warnings = [
|
|
193
|
+
...leftOut,
|
|
194
|
+
...uploaded.services.flatMap((service) => (variables.get(service.name)?.expectedButMissing ?? []).map((key) => `${service.name} expects ${key}, which has no value`)),
|
|
195
|
+
...uploaded.leftRunning.map((name) => `${name} is no longer in this folder, and keeps running`),
|
|
196
|
+
];
|
|
197
|
+
if (warnings.length > 0)
|
|
198
|
+
process.stdout.write(`\n${warnings.map(warning).join('\n')}\n`);
|
|
199
|
+
process.stdout.write('\n');
|
|
200
|
+
}
|
|
201
|
+
async function watchDeployment(api, project, deploymentId, format) {
|
|
202
|
+
const stopWatching = () => {
|
|
203
|
+
process.stderr.write(`\nThe deploy goes on without this terminal. To follow it, run\ngrayspar status --project ${project.name}\n`);
|
|
204
|
+
process.exit(130);
|
|
205
|
+
};
|
|
206
|
+
process.once('SIGINT', stopWatching);
|
|
207
|
+
try {
|
|
208
|
+
return await awaitDeployment(api, project, deploymentId, { format, label: 'Deploying…' });
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
process.off('SIGINT', stopWatching);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async function printOutcome(api, project, outcome, { uploaded, serviceFlags }) {
|
|
215
|
+
if (outcome.status === 'live') {
|
|
216
|
+
const live = await dataOrThrow(api.GET('/api/projects/{id}', { params: { path: { id: project.id } } }));
|
|
217
|
+
const ports = portLines(outcome.listening);
|
|
218
|
+
process.stdout.write(`${[receipt(`Deployed ${live.name}`), ...ports.receipts].join('\n')}\n\n${servicesTable(live)}\n` +
|
|
219
|
+
(ports.warnings.length === 0 ? '' : `\n${ports.warnings.join('\n')}\n`));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (outcome.status === 'pending') {
|
|
223
|
+
process.stdout.write(`Still building. To follow it, run\ngrayspar logs --build --project ${project.name}\n`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
await reportFailure(api, project, outcome, dockerfileHint(uploaded, outcome, serviceFlags));
|
|
227
|
+
}
|
|
228
|
+
function dockerfileHint(uploaded, outcome, serviceFlags) {
|
|
229
|
+
const service = uploaded.services.find((candidate) => candidate.name === outcome.failedService);
|
|
230
|
+
if (outcome.built ||
|
|
231
|
+
service === undefined ||
|
|
232
|
+
service.builder !== 'dockerfile' ||
|
|
233
|
+
service.dockerfile !== null ||
|
|
234
|
+
serviceFlags.dockerfiles.has(service.name) ||
|
|
235
|
+
service.rootDirectory === '.') {
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
const path = `${service.rootDirectory}/Dockerfile`;
|
|
239
|
+
return [
|
|
240
|
+
`If ${path} builds from the project's root, run`,
|
|
241
|
+
`grayspar service set ${service.name} --dockerfile ${path}`,
|
|
242
|
+
'and deploy again.',
|
|
243
|
+
];
|
|
244
|
+
}
|
|
245
|
+
export function destinationOptions(projects, suggestedName) {
|
|
246
|
+
const match = projects.find((project) => project.name === suggestedName);
|
|
247
|
+
const others = projects
|
|
248
|
+
.filter((project) => project !== match)
|
|
249
|
+
.map((project) => ({ kind: 'existing', project }));
|
|
250
|
+
const create = { kind: 'create' };
|
|
251
|
+
return match === undefined
|
|
252
|
+
? [create, ...others]
|
|
253
|
+
: [{ kind: 'existing', project: match }, create, ...others];
|
|
254
|
+
}
|
|
255
|
+
const NAME_RULE = 'Names use lowercase letters, numbers and single hyphens, up to 58 characters.';
|
|
256
|
+
function nameProblem(name, projects) {
|
|
257
|
+
if (projectNameFrom(name) !== name)
|
|
258
|
+
return NAME_RULE;
|
|
259
|
+
return projects.some((project) => project.name === name)
|
|
260
|
+
? `This team already has a project called ${name}.`
|
|
261
|
+
: null;
|
|
262
|
+
}
|
|
263
|
+
function givenName(name, projects) {
|
|
264
|
+
const problem = name === null ? NAME_RULE : nameProblem(name, projects);
|
|
265
|
+
if (name === null || problem !== null) {
|
|
266
|
+
throw new CliError('needs_a_name', `${problem ?? NAME_RULE}\nName the project with --name.`, '--name');
|
|
267
|
+
}
|
|
268
|
+
return name;
|
|
269
|
+
}
|
|
270
|
+
async function askProjectName(projects, suggestion) {
|
|
271
|
+
let offered = suggestion;
|
|
272
|
+
for (;;) {
|
|
273
|
+
const answer = await askText('Project name', offered, '--name');
|
|
274
|
+
const problem = nameProblem(answer, projects);
|
|
275
|
+
if (problem === null)
|
|
276
|
+
return answer;
|
|
277
|
+
process.stdout.write(`${failure(problem)}\n`);
|
|
278
|
+
offered = projectNameFrom(answer);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async function destinationFor(api, root, options) {
|
|
282
|
+
if (options.name !== undefined && projectIsNamed()) {
|
|
283
|
+
throw new CliError('name_and_project', '--name creates a new project and --project names an existing one.\nGive one of them.', '--name');
|
|
284
|
+
}
|
|
285
|
+
if (projectIsNamed()) {
|
|
286
|
+
return {
|
|
287
|
+
kind: 'project',
|
|
288
|
+
project: await resolveProject(api, { followLink: false }),
|
|
289
|
+
isLinked: false,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const link = await findLink(root);
|
|
293
|
+
if (link !== null) {
|
|
294
|
+
const answer = await api.GET('/api/projects/{id}', {
|
|
295
|
+
params: { path: { id: link.projectId } },
|
|
296
|
+
});
|
|
297
|
+
if (answer.response.status !== 404) {
|
|
298
|
+
if (options.name !== undefined) {
|
|
299
|
+
throw new CliError('already_linked', 'This folder is already linked to a project.\nRun grayspar unlink to deploy it as a new one.', '--name');
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
kind: 'project',
|
|
303
|
+
project: await dataOrThrow(Promise.resolve(answer)),
|
|
304
|
+
isLinked: true,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
printNote(options.format, 'The project this folder was linked to no longer exists.');
|
|
308
|
+
}
|
|
309
|
+
const team = await resolveTeam(api, { takeDefault: options.yes, deploying: true });
|
|
310
|
+
const projects = await teamProjects(api, team.id);
|
|
311
|
+
if (options.name !== undefined) {
|
|
312
|
+
return { kind: 'new', team, name: givenName(options.name, projects) };
|
|
313
|
+
}
|
|
314
|
+
const suggestion = projectNameFrom(basename(root));
|
|
315
|
+
const destinations = destinationOptions(projects, suggestion);
|
|
316
|
+
const [suggested] = destinations;
|
|
317
|
+
if (suggested === undefined)
|
|
318
|
+
throw new Error('There is always a project to create.');
|
|
319
|
+
if (options.yes) {
|
|
320
|
+
return suggested.kind === 'existing'
|
|
321
|
+
? { kind: 'project', project: suggested.project, isLinked: false }
|
|
322
|
+
: { kind: 'new', team, name: givenName(suggestion, projects) };
|
|
323
|
+
}
|
|
324
|
+
if (!isInteractive()) {
|
|
325
|
+
throw new CliError('needs_an_answer', 'This folder is not linked to a project yet, and there is no terminal to ask which.\nDeploy to a new project with --name, or to an existing one with --project.', '--name');
|
|
326
|
+
}
|
|
327
|
+
const picked = await askChoice('Select a project', 'Project', destinations.map((option, index) => ({
|
|
328
|
+
value: String(index),
|
|
329
|
+
label: option.kind === 'create' ? 'Create a new project' : option.project.name,
|
|
330
|
+
})), '--project');
|
|
331
|
+
const choice = destinations[Number(picked)] ?? suggested;
|
|
332
|
+
const destination = choice.kind === 'existing'
|
|
333
|
+
? { kind: 'project', project: choice.project, isLinked: false }
|
|
334
|
+
: { kind: 'new', team, name: await askProjectName(projects, suggestion) };
|
|
335
|
+
process.stdout.write('\n');
|
|
336
|
+
return destination;
|
|
337
|
+
}
|
|
338
|
+
export const deployCommand = new Command('deploy')
|
|
339
|
+
.argument('[directory]', 'the project folder to deploy', '.')
|
|
340
|
+
.description('build a project folder and put it online')
|
|
341
|
+
.option('-y, --yes', 'take the plan as shown without asking')
|
|
342
|
+
.option('--name <name>', 'create a new project with this name')
|
|
343
|
+
.option('--no-env', 'deploy without reading any .env file')
|
|
344
|
+
.option('--env-file <path>', 'read variables from a specific file')
|
|
345
|
+
.addOption(new Option('--env-conflict <winner>', 'which value wins where your .env differs from a saved one').choices(['saved', 'file']))
|
|
346
|
+
.option('-s, --service <name>', 'deploy only this service, repeatable', collectRepeatedValue)
|
|
347
|
+
.option('--path <name=path>', 'where a new service answers, repeatable', collectRepeatedValue)
|
|
348
|
+
.option('--dockerfile <name=path>', 'the Dockerfile a new service builds from', collectRepeatedValue)
|
|
349
|
+
.option('--volume <name=path>', 'a folder a service keeps across deploys, repeatable', collectRepeatedValue)
|
|
350
|
+
.action(commandAction((format, command, folder) => {
|
|
351
|
+
const options = command.opts();
|
|
352
|
+
return deploy(folder, {
|
|
353
|
+
format,
|
|
354
|
+
yes: options.yes === true,
|
|
355
|
+
name: options.name,
|
|
356
|
+
env: options.env,
|
|
357
|
+
envFile: options.envFile,
|
|
358
|
+
preference: options.envConflict,
|
|
359
|
+
flags: {
|
|
360
|
+
services: options.service ?? [],
|
|
361
|
+
paths: options.path ?? [],
|
|
362
|
+
dockerfiles: options.dockerfile ?? [],
|
|
363
|
+
volumes: options.volume ?? [],
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
}));
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/* A variable found beside the service beats one at the repository root, and an example file is read for its names
|
|
2
|
+
alone, because its values are placeholders. A variable Grayspar sets itself is left out wherever it is found. */
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { parseEnv } from 'node:util';
|
|
6
|
+
import { CliError } from '../../lib/errors.js';
|
|
7
|
+
const ENV_FILES_IN_ORDER = ['.env', '.env.production', '.env.local'];
|
|
8
|
+
const EXAMPLE_ENV_FILES = ['.env.example', '.env.sample', '.env.template'];
|
|
9
|
+
export function emptyFoundVariables() {
|
|
10
|
+
return { values: {}, sourceFiles: {}, skippedPlatformVariables: {}, expectedButMissing: [] };
|
|
11
|
+
}
|
|
12
|
+
async function readIfPresent(folder, file) {
|
|
13
|
+
return readFile(join(folder, file), 'utf8').catch(() => null);
|
|
14
|
+
}
|
|
15
|
+
export async function variablesFor(root, serviceFolder, platformVariableNames, envFile) {
|
|
16
|
+
const values = {};
|
|
17
|
+
const sourceFiles = {};
|
|
18
|
+
const skippedPlatformVariables = {};
|
|
19
|
+
const expected = new Set();
|
|
20
|
+
function takeVariables(raw, origin) {
|
|
21
|
+
for (const [name, value] of Object.entries(parseEnv(raw))) {
|
|
22
|
+
if (value === undefined)
|
|
23
|
+
continue;
|
|
24
|
+
if (platformVariableNames.includes(name)) {
|
|
25
|
+
skippedPlatformVariables[name] = origin;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
values[name] = value;
|
|
29
|
+
sourceFiles[name] = origin;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const places = serviceFolder === '.' ? ['.'] : ['.', serviceFolder];
|
|
33
|
+
for (const place of places) {
|
|
34
|
+
for (const file of ENV_FILES_IN_ORDER) {
|
|
35
|
+
const raw = await readIfPresent(join(root, place), file);
|
|
36
|
+
if (raw === null)
|
|
37
|
+
continue;
|
|
38
|
+
takeVariables(raw, place === '.' ? file : `${place}/${file}`);
|
|
39
|
+
}
|
|
40
|
+
for (const file of EXAMPLE_ENV_FILES) {
|
|
41
|
+
const raw = await readIfPresent(join(root, place), file);
|
|
42
|
+
if (raw === null)
|
|
43
|
+
continue;
|
|
44
|
+
for (const name of Object.keys(parseEnv(raw)))
|
|
45
|
+
expected.add(name);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (envFile !== undefined) {
|
|
49
|
+
const raw = await readIfPresent(root, envFile);
|
|
50
|
+
if (raw === null) {
|
|
51
|
+
throw new CliError('no_env_file', `No environment file at ${envFile}.\nGive the path of an existing file with --env-file.`, '--env-file');
|
|
52
|
+
}
|
|
53
|
+
takeVariables(raw, envFile);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
values,
|
|
57
|
+
sourceFiles,
|
|
58
|
+
skippedPlatformVariables,
|
|
59
|
+
expectedButMissing: [...expected]
|
|
60
|
+
.filter((name) => !(name in values) && !platformVariableNames.includes(name))
|
|
61
|
+
.sort(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* A deploy uploads the files on disk and names the commit only to describe them, so a tree with
|
|
2
|
+
uncommitted or untracked files says it holds changes beyond that commit. */
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
const GIT_TIMEOUT_MS = 5000;
|
|
7
|
+
async function runGit(folder, args) {
|
|
8
|
+
const { stdout } = await execFileAsync('git', args, { cwd: folder, timeout: GIT_TIMEOUT_MS });
|
|
9
|
+
return stdout.trim();
|
|
10
|
+
}
|
|
11
|
+
export async function gitStateOf(folder) {
|
|
12
|
+
try {
|
|
13
|
+
const commit = await runGit(folder, ['rev-parse', 'HEAD']);
|
|
14
|
+
const branch = await runGit(folder, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
15
|
+
const message = await runGit(folder, ['log', '-1', '--format=%s']);
|
|
16
|
+
const changes = await runGit(folder, ['status', '--porcelain']);
|
|
17
|
+
return { branch: branch === 'HEAD' ? null : branch, commit, message, dirty: changes !== '' };
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/* A project's name is its address too, so it holds only what an address can carry. The API decides; this only turns a
|
|
2
|
+
folder name or a typed answer into the nearest name it would accept, to offer as the answer. */
|
|
3
|
+
const NAME_LIMIT = 58;
|
|
4
|
+
export function projectNameFrom(text) {
|
|
5
|
+
const name = text
|
|
6
|
+
.toLowerCase()
|
|
7
|
+
.normalize('NFKD')
|
|
8
|
+
.replace(/\p{M}+/gu, '')
|
|
9
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
10
|
+
.slice(0, NAME_LIMIT)
|
|
11
|
+
.replace(/^-+|-+$/g, '');
|
|
12
|
+
return name === '' ? null : name;
|
|
13
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/* The saved variables are the source of truth, so a deploy adds what the project lacks and asks
|
|
2
|
+
before a local value replaces a saved one that differs; the server answers without the values. */
|
|
3
|
+
import { stat } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { dataOrThrow } from '../../lib/client.js';
|
|
6
|
+
import { CliError } from '../../lib/errors.js';
|
|
7
|
+
import { askChoice, isInteractive } from '../../lib/prompt.js';
|
|
8
|
+
import { color } from '../../lib/ui.js';
|
|
9
|
+
export function sendableVariables(services, variables, comparison, fileWins) {
|
|
10
|
+
return Object.fromEntries(services.map((service, index) => {
|
|
11
|
+
const values = variables[index]?.values ?? {};
|
|
12
|
+
const kept = Object.entries(values).filter(([key]) => {
|
|
13
|
+
const state = comparison[service]?.[key]?.state ?? 'new';
|
|
14
|
+
if (state === 'new')
|
|
15
|
+
return true;
|
|
16
|
+
if (state === 'same')
|
|
17
|
+
return false;
|
|
18
|
+
return fileWins({ service, key });
|
|
19
|
+
});
|
|
20
|
+
return [service, Object.fromEntries(kept)];
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
function timeAgo(at) {
|
|
24
|
+
const minutes = Math.max(0, Math.floor((Date.now() - at.getTime()) / 60_000));
|
|
25
|
+
if (minutes < 1)
|
|
26
|
+
return 'just now';
|
|
27
|
+
if (minutes < 60)
|
|
28
|
+
return `${minutes}m ago`;
|
|
29
|
+
if (minutes < 60 * 24)
|
|
30
|
+
return `${Math.floor(minutes / 60)}h ago`;
|
|
31
|
+
return `${Math.floor(minutes / (60 * 24))}d ago`;
|
|
32
|
+
}
|
|
33
|
+
async function editedAt(root, file) {
|
|
34
|
+
if (file === undefined)
|
|
35
|
+
return null;
|
|
36
|
+
return stat(join(root, file)).then((found) => found.mtime, () => null);
|
|
37
|
+
}
|
|
38
|
+
export async function variablesToSend(api, project, root, services, variables, preference) {
|
|
39
|
+
const local = Object.fromEntries(services.map((service, index) => [service, variables[index]?.values ?? {}]));
|
|
40
|
+
const comparison = await dataOrThrow(api.POST('/api/projects/{id}/env/compare', {
|
|
41
|
+
params: { path: { id: project.id } },
|
|
42
|
+
body: { services: local },
|
|
43
|
+
}));
|
|
44
|
+
const conflicts = services.flatMap((service) => Object.entries(comparison[service] ?? {})
|
|
45
|
+
.filter(([, entry]) => entry.state === 'different')
|
|
46
|
+
.map(([key]) => ({ service, key })));
|
|
47
|
+
if (conflicts.length === 0 || preference !== undefined) {
|
|
48
|
+
return sendableVariables(services, variables, comparison, () => preference === 'file');
|
|
49
|
+
}
|
|
50
|
+
const names = conflicts.map(({ service, key }) => `${key} on ${service}`).join(', ');
|
|
51
|
+
if (!isInteractive()) {
|
|
52
|
+
throw new CliError('env_differs', `${names} ${conflicts.length === 1 ? 'differs from its saved value' : 'differ from their saved values'}. Pass --env-conflict saved or --env-conflict file.`, '--env-conflict');
|
|
53
|
+
}
|
|
54
|
+
const fileWinsByConflict = new Map();
|
|
55
|
+
for (const { service, key } of conflicts) {
|
|
56
|
+
const saved = comparison[service]?.[key]?.savedAt;
|
|
57
|
+
const index = services.indexOf(service);
|
|
58
|
+
const file = variables[index]?.sourceFiles[key];
|
|
59
|
+
const edited = await editedAt(root, file);
|
|
60
|
+
const savedAndEditedNote = `Saved ${saved ? timeAgo(new Date(saved)) : 'earlier'}${edited === null ? '' : `, ${file ?? '.env'} edited ${timeAgo(edited)}`}`;
|
|
61
|
+
process.stdout.write(`\n${color.heading(key)} on ${service} differs from the saved value.\n${savedAndEditedNote}.\n`);
|
|
62
|
+
const pick = await askChoice(`Select the value of ${key} for ${service}`, `${key} for ${service}`, [
|
|
63
|
+
{ value: 'saved', label: 'Keep the saved value' },
|
|
64
|
+
{ value: 'file', label: `Use the one in ${file ?? '.env'}` },
|
|
65
|
+
], '--env-conflict');
|
|
66
|
+
fileWinsByConflict.set(`${service} ${key}`, pick === 'file');
|
|
67
|
+
}
|
|
68
|
+
return sendableVariables(services, variables, comparison, ({ service, key }) => fileWinsByConflict.get(`${service} ${key}`) === true);
|
|
69
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/* Saving a variable redeploys whatever is live, so a change is one call and the command waits for it
|
|
2
|
+
the way deploy does. */
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { dataOrThrow, signedInClient } from '../lib/client.js';
|
|
5
|
+
import { commandAction, serviceNameOf, serviceOption } from '../lib/command-helpers.js';
|
|
6
|
+
import { CliError } from '../lib/errors.js';
|
|
7
|
+
import { appliesOnNextDeploy, followRedeploy } from '../lib/follow-deployment.js';
|
|
8
|
+
import { printResult } from '../lib/output.js';
|
|
9
|
+
import { resolveService, resolveServices } from '../lib/resolve-service.js';
|
|
10
|
+
import { tableOrEmptyNote } from '../lib/ui.js';
|
|
11
|
+
async function saveVariables(api, project, service, change, format, receipt) {
|
|
12
|
+
const saved = await dataOrThrow(api.PUT('/api/projects/{id}/services/{serviceId}/env', {
|
|
13
|
+
params: { path: { id: project.id, serviceId: service.id } },
|
|
14
|
+
body: change,
|
|
15
|
+
}));
|
|
16
|
+
await followRedeploy(api, project, saved.deployment, format, {
|
|
17
|
+
receipts: [receipt],
|
|
18
|
+
whenNothingIsLive: appliesOnNextDeploy(project),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
async function setVariables(pairs, serviceName, format) {
|
|
22
|
+
const api = await signedInClient();
|
|
23
|
+
const { project, service } = await resolveService(api, serviceName);
|
|
24
|
+
const variables = {};
|
|
25
|
+
for (const pair of pairs) {
|
|
26
|
+
const equals = pair.indexOf('=');
|
|
27
|
+
if (equals <= 0) {
|
|
28
|
+
throw new CliError('bad_variable', `Write each variable as KEY=VALUE. Got ${pair}.`);
|
|
29
|
+
}
|
|
30
|
+
variables[pair.slice(0, equals)] = pair.slice(equals + 1);
|
|
31
|
+
}
|
|
32
|
+
await saveVariables(api, project, service, { variables }, format, `Set ${Object.keys(variables).join(', ')} on ${service.name}`);
|
|
33
|
+
}
|
|
34
|
+
async function listVariables(serviceName, format) {
|
|
35
|
+
const api = await signedInClient();
|
|
36
|
+
const { project, services } = await resolveServices(api, serviceName);
|
|
37
|
+
const listed = await Promise.all(services.map(async (service) => ({
|
|
38
|
+
service: service.name,
|
|
39
|
+
variables: await dataOrThrow(api.GET('/api/projects/{id}/services/{serviceId}/env', {
|
|
40
|
+
params: { path: { id: project.id, serviceId: service.id } },
|
|
41
|
+
})),
|
|
42
|
+
})));
|
|
43
|
+
printResult(format, listed, () => {
|
|
44
|
+
const rows = listed.flatMap(({ service, variables }) => variables.map((variable) => [service, variable.name, variable.updatedAt.slice(0, 10)]));
|
|
45
|
+
return tableOrEmptyNote(['SERVICE', 'NAME', 'UPDATED'], rows, `${serviceName ?? project.name} has no variables.`);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async function removeVariables(keys, serviceName, format) {
|
|
49
|
+
const api = await signedInClient();
|
|
50
|
+
const { project, service } = await resolveService(api, serviceName);
|
|
51
|
+
await saveVariables(api, project, service, { remove: keys }, format, `Removed ${keys.join(', ')} from ${service.name}`);
|
|
52
|
+
}
|
|
53
|
+
export const envCommand = new Command('env').description("set a project's environment variables");
|
|
54
|
+
envCommand
|
|
55
|
+
.command('list', { isDefault: true })
|
|
56
|
+
.addOption(serviceOption('which service to read'))
|
|
57
|
+
.description("list the names of a service's variables")
|
|
58
|
+
.action(commandAction((format, command) => listVariables(serviceNameOf(command), format)));
|
|
59
|
+
envCommand
|
|
60
|
+
.command('set')
|
|
61
|
+
.argument('<pairs...>', 'variables written as KEY=VALUE')
|
|
62
|
+
.addOption(serviceOption('which service to set them on'))
|
|
63
|
+
.description('add or change environment variables')
|
|
64
|
+
.action(commandAction((format, command, pairs) => setVariables(pairs, serviceNameOf(command), format)));
|
|
65
|
+
envCommand
|
|
66
|
+
.command('remove')
|
|
67
|
+
.argument('<keys...>', 'the names of the variables to remove')
|
|
68
|
+
.addOption(serviceOption('which service to change'))
|
|
69
|
+
.description('remove environment variables')
|
|
70
|
+
.action(commandAction((format, command, keys) => removeVariables(keys, serviceNameOf(command), format)));
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { signedInClient } from '../lib/client.js';
|
|
3
|
+
import { commandAction } from '../lib/command-helpers.js';
|
|
4
|
+
import { CliError } from '../lib/errors.js';
|
|
5
|
+
import { printResult } from '../lib/output.js';
|
|
6
|
+
import { removeLink, writeLink } from '../lib/project-link.js';
|
|
7
|
+
import { resolveProject } from '../lib/resolve-project.js';
|
|
8
|
+
import { receipt } from '../lib/ui.js';
|
|
9
|
+
async function link(format) {
|
|
10
|
+
const api = await signedInClient();
|
|
11
|
+
const project = await resolveProject(api, { followLink: false });
|
|
12
|
+
await writeLink(process.cwd(), { projectId: project.id });
|
|
13
|
+
printResult(format, { status: 'ok', project: project.name }, () => receipt(`Linked this folder to ${project.name}`));
|
|
14
|
+
}
|
|
15
|
+
async function unlink(format) {
|
|
16
|
+
const existed = await removeLink(process.cwd());
|
|
17
|
+
if (!existed) {
|
|
18
|
+
throw new CliError('not_linked', 'This folder is not linked to a project.');
|
|
19
|
+
}
|
|
20
|
+
printResult(format, { status: 'ok' }, () => receipt('Unlinked this folder'));
|
|
21
|
+
}
|
|
22
|
+
export const linkCommand = new Command('link')
|
|
23
|
+
.description('bind this folder to a project')
|
|
24
|
+
.action(commandAction((format) => link(format)));
|
|
25
|
+
export const unlinkCommand = new Command('unlink')
|
|
26
|
+
.description('unbind this folder from its project')
|
|
27
|
+
.action(commandAction((format) => unlink(format)));
|