@grayspar/cli 0.0.1 → 0.0.2
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 +3 -3
- package/dist/commands/access.js +12 -12
- package/dist/commands/deploy/archive.js +124 -35
- package/dist/commands/deploy/await-discovery.js +17 -0
- package/dist/commands/deploy/deploy-destination.js +101 -0
- package/dist/commands/deploy/deploy-flags.js +10 -4
- package/dist/commands/deploy/deploy-target.js +19 -0
- package/dist/commands/deploy/deploy.js +119 -276
- package/dist/commands/deploy/env-files.js +7 -23
- package/dist/commands/deploy/git-state.js +19 -6
- package/dist/commands/deploy/submit-deploy.js +127 -0
- package/dist/commands/deploy/variables-to-send.js +83 -12
- package/dist/commands/env.js +13 -12
- package/dist/commands/link.js +6 -5
- package/dist/commands/login/login.js +9 -6
- package/dist/commands/login/open-browser.js +2 -2
- package/dist/commands/login/sign-in-callback.js +5 -4
- package/dist/commands/logout.js +5 -17
- package/dist/commands/logs.js +23 -18
- package/dist/commands/open-path.js +4 -4
- package/dist/commands/restart.js +7 -5
- package/dist/commands/service.js +16 -25
- package/dist/commands/volume.js +33 -19
- package/dist/index.js +6 -1
- package/dist/lib/client.js +33 -7
- package/dist/lib/credentials.js +14 -3
- package/dist/lib/follow-deployment.js +79 -51
- package/dist/lib/output.js +6 -2
- package/dist/lib/project-link.js +2 -5
- package/dist/lib/prompt.js +43 -5
- package/dist/lib/resolve-project.js +6 -2
- package/dist/lib/resolve-team.js +1 -1
- package/dist/lib/retry-transient.js +32 -0
- package/dist/lib/services-table.js +1 -0
- package/dist/lib/usage-errors.js +43 -0
- package/dist/lib/version.js +1 -1
- package/package.json +6 -1
- package/dist/lib/listening.js +0 -24
|
@@ -1,207 +1,161 @@
|
|
|
1
|
-
/* Upload,
|
|
2
|
-
|
|
3
|
-
import {
|
|
1
|
+
/* Upload the project, send the deploy and follow it until it is live or has failed. */
|
|
2
|
+
import { openAsBlob, rmSync } from 'node:fs';
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
4
6
|
import { Command, Option } from 'commander';
|
|
5
7
|
import { dataOrThrow, signedInClient } from '../../lib/client.js';
|
|
6
8
|
import { collectRepeatedValue, commandAction } from '../../lib/command-helpers.js';
|
|
7
9
|
import { CliError } from '../../lib/errors.js';
|
|
8
|
-
import { awaitDeployment,
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { projectIsNamed, resolveProject, teamProjects } from '../../lib/resolve-project.js';
|
|
14
|
-
import { resolveTeam } from '../../lib/resolve-team.js';
|
|
10
|
+
import { awaitDeployment, printDeployFailure, printReplacedDeploy, } from '../../lib/follow-deployment.js';
|
|
11
|
+
import { startProgress } from '../../lib/output.js';
|
|
12
|
+
import { writeLink } from '../../lib/project-link.js';
|
|
13
|
+
import { CTRL_C_EXIT_CODE } from '../../lib/prompt.js';
|
|
14
|
+
import { commandFor } from '../../lib/resolve-project.js';
|
|
15
15
|
import { servicesTable } from '../../lib/services-table.js';
|
|
16
|
-
import { color,
|
|
16
|
+
import { color, receipt, table, warning } from '../../lib/ui.js';
|
|
17
17
|
import { archiveOf } from './archive.js';
|
|
18
|
-
import {
|
|
18
|
+
import { awaitDiscovery } from './await-discovery.js';
|
|
19
|
+
import { destinationFor } from './deploy-destination.js';
|
|
20
|
+
import { NO_PATH_VALUE, parseDeployFlags, servicesToSend, } from './deploy-flags.js';
|
|
21
|
+
import { deployTarget } from './deploy-target.js';
|
|
19
22
|
import { emptyFoundVariables, variablesFor } from './env-files.js';
|
|
20
23
|
import { gitStateOf } from './git-state.js';
|
|
21
|
-
import {
|
|
24
|
+
import { submitDeploy } from './submit-deploy.js';
|
|
22
25
|
import { variablesToSend } from './variables-to-send.js';
|
|
26
|
+
const UPLOAD_TIMEOUT_MS = 30 * 60 * 1000;
|
|
23
27
|
async function deploy(folder, options) {
|
|
24
|
-
const root =
|
|
28
|
+
const { link, root, under } = await deployTarget(process.cwd(), folder);
|
|
25
29
|
const serviceFlags = parseDeployFlags(options.flags);
|
|
26
30
|
const api = await signedInClient();
|
|
27
|
-
const destination = await destinationFor(api, root, options);
|
|
31
|
+
const destination = await destinationFor(api, root, link, options);
|
|
28
32
|
const inspecting = startProgress(options.format, 'Inspecting your project…');
|
|
29
|
-
const uploaded = await uploadSource(api, destination,
|
|
33
|
+
const uploaded = await uploadSource(api, destination, { root, under }, inspecting).finally(inspecting.stop);
|
|
30
34
|
const variables = new Map(await Promise.all(uploaded.services.map(async (service) => [
|
|
31
35
|
service.name,
|
|
32
36
|
options.env
|
|
33
|
-
? await variablesFor(root, service.
|
|
37
|
+
? await variablesFor(root, service.rootFolder, uploaded.platformVariables, options.envFile)
|
|
34
38
|
: emptyFoundVariables(),
|
|
35
39
|
])));
|
|
36
40
|
if (options.format === 'text')
|
|
37
|
-
printFoundServices(uploaded, variables);
|
|
41
|
+
printFoundServices(uploaded, variables, under === '.');
|
|
38
42
|
const foundNames = uploaded.services.map((service) => service.name);
|
|
39
43
|
const deploying = servicesToSend(foundNames, serviceFlags).flatMap(({ name }) => {
|
|
40
44
|
const known = variables.get(name);
|
|
41
45
|
return known === undefined ? [] : [{ name, foundVariables: known }];
|
|
42
46
|
});
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
:
|
|
46
|
-
|
|
47
|
-
const { project, deployment } = await submitDeploy(api, destination, options, serviceFlags, foundNames, {
|
|
48
|
-
upload: uploaded.upload,
|
|
49
|
-
variables: toSend,
|
|
50
|
-
...(gitState === null ? {} : { git: gitState }),
|
|
47
|
+
const chosen = await variablesToSend(api, destination.kind === 'project' ? destination.project : null, root, deploying.map((service) => service.name), deploying.map((service) => service.foundVariables), options.envConflict, {
|
|
48
|
+
projectName: destination.kind === 'project' ? destination.project.name : destination.name,
|
|
49
|
+
format: options.format,
|
|
50
|
+
yes: options.yes,
|
|
51
51
|
});
|
|
52
|
-
|
|
52
|
+
const { project, deployment } = await submitDeploy(api, destination, options, serviceFlags, foundNames, { upload: uploaded.upload, variables: chosen.variables });
|
|
53
|
+
if (destination.kind === 'new' || destination.linksFolder) {
|
|
53
54
|
await writeLink(root, { projectId: project.id });
|
|
54
55
|
}
|
|
55
56
|
if (options.format === 'text') {
|
|
56
|
-
for (const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
57
|
+
for (const { service, addedVolume } of deployment.services) {
|
|
58
|
+
if (addedVolume === null)
|
|
59
|
+
continue;
|
|
60
|
+
process.stdout.write(`${receipt(`Added volume ${addedVolume} to ${service}`)}\n`);
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
63
|
const outcome = await watchDeployment(api, project, deployment.id, options.format);
|
|
63
64
|
if (options.format === 'json') {
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
const variables = {
|
|
66
|
+
sent: Object.entries(chosen.variables).flatMap(([service, values]) => Object.keys(values).map((key) => ({ service, key }))),
|
|
67
|
+
leftOut: chosen.leftOut.map(({ service, key }) => ({ service, key })),
|
|
68
|
+
};
|
|
69
|
+
process.stdout.write(`${JSON.stringify({ project: project.name, url: project.url, variables, ...outcome }, null, 2)}\n`);
|
|
70
|
+
if (outcome.status !== 'live')
|
|
66
71
|
process.exitCode = 1;
|
|
67
72
|
return;
|
|
68
73
|
}
|
|
69
|
-
await printOutcome(api, project, outcome
|
|
70
|
-
}
|
|
71
|
-
function
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
await printOutcome(api, project, outcome);
|
|
75
|
+
}
|
|
76
|
+
async function uploadLimitsOf(api, destination) {
|
|
77
|
+
if (destination.kind === 'new')
|
|
78
|
+
return destination.team.uploadLimits;
|
|
79
|
+
const teams = await dataOrThrow(api.GET('/api/teams'));
|
|
80
|
+
const team = teams.find((candidate) => candidate.id === destination.project.teamId);
|
|
81
|
+
if (team === undefined)
|
|
82
|
+
throw new Error(`team ${destination.project.teamId} is not in /api/teams`);
|
|
83
|
+
return team.uploadLimits;
|
|
84
|
+
}
|
|
85
|
+
async function uploadSource(api, destination, source, progress) {
|
|
86
|
+
const scratch = await mkdtemp(join(tmpdir(), 'grayspar-'));
|
|
87
|
+
const removeScratchAndExit = () => {
|
|
88
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
89
|
+
process.exit(CTRL_C_EXIT_CODE);
|
|
77
90
|
};
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
|
|
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;
|
|
91
|
+
process.once('SIGINT', removeScratchAndExit);
|
|
92
|
+
try {
|
|
93
|
+
const archive = await archiveOf(source.root, source.under, scratch, await uploadLimitsOf(api, destination));
|
|
94
|
+
const gitState = await gitStateOf(join(source.root, source.under));
|
|
95
|
+
const body = {
|
|
96
|
+
bytes: archive.bytes,
|
|
97
|
+
sha256: archive.sha256,
|
|
98
|
+
...(gitState === null ? {} : { gitMetadata: gitState }),
|
|
99
|
+
};
|
|
100
|
+
const created = await dataOrThrow(destination.kind === 'project'
|
|
101
|
+
? api.POST('/api/projects/{projectId}/uploads', {
|
|
102
|
+
params: { path: { projectId: destination.project.id } },
|
|
103
|
+
body,
|
|
104
|
+
})
|
|
105
|
+
: api.POST('/api/teams/{teamId}/uploads', {
|
|
106
|
+
params: { path: { teamId: destination.team.id } },
|
|
107
|
+
body,
|
|
108
|
+
}));
|
|
109
|
+
progress.update('Uploading…');
|
|
110
|
+
const stored = await fetch(created.url, {
|
|
111
|
+
method: 'PUT',
|
|
112
|
+
body: await openAsBlob(archive.path),
|
|
113
|
+
headers: created.headers,
|
|
114
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
|
115
|
+
}).catch(() => null);
|
|
116
|
+
if (stored?.ok !== true) {
|
|
117
|
+
throw new CliError('upload_failed', "Your project didn't finish uploading. Deploy again.");
|
|
170
118
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
119
|
+
progress.update('Inspecting your project…');
|
|
120
|
+
await dataOrThrow(destination.kind === 'project'
|
|
121
|
+
? api.POST('/api/projects/{projectId}/uploads/{uploadId}/discover', {
|
|
122
|
+
params: { path: { projectId: destination.project.id, uploadId: created.upload } },
|
|
123
|
+
})
|
|
124
|
+
: api.POST('/api/teams/{teamId}/uploads/{uploadId}/discover', {
|
|
125
|
+
params: { path: { teamId: destination.team.id, uploadId: created.upload } },
|
|
126
|
+
}));
|
|
127
|
+
return await awaitDiscovery(api, destination, created.upload);
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
process.off('SIGINT', removeScratchAndExit);
|
|
131
|
+
await rm(scratch, { recursive: true, force: true });
|
|
179
132
|
}
|
|
180
133
|
}
|
|
181
|
-
function
|
|
182
|
-
return destination.kind === 'project'
|
|
183
|
-
? new URL(destination.project.url).host
|
|
184
|
-
: "the project's address";
|
|
185
|
-
}
|
|
186
|
-
function printFoundServices(uploaded, variables) {
|
|
134
|
+
function printFoundServices(uploaded, variables, isWholeProject) {
|
|
187
135
|
const count = uploaded.services.length;
|
|
188
136
|
const rows = uploaded.services.map((service) => [service.name, service.builtWith ?? '…']);
|
|
189
|
-
|
|
190
|
-
|
|
137
|
+
const found = count === 0
|
|
138
|
+
? []
|
|
139
|
+
: [
|
|
140
|
+
`${color.heading(`Found ${String(count)} ${count === 1 ? 'service' : 'services'}`)}\n\n` +
|
|
141
|
+
table(['SERVICE', 'BUILT WITH'], rows),
|
|
142
|
+
];
|
|
191
143
|
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
144
|
const warnings = [
|
|
193
145
|
...leftOut,
|
|
194
|
-
...
|
|
195
|
-
|
|
146
|
+
...(isWholeProject
|
|
147
|
+
? uploaded.leftRunning.map((name) => `${name} is no longer in this folder, and keeps running`)
|
|
148
|
+
: []),
|
|
196
149
|
];
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
150
|
+
const blocks = [...found, ...(warnings.length > 0 ? [warnings.map(warning).join('\n')] : [])];
|
|
151
|
+
if (blocks.length > 0)
|
|
152
|
+
process.stdout.write(`${blocks.join('\n\n')}\n\n`);
|
|
200
153
|
}
|
|
201
154
|
async function watchDeployment(api, project, deploymentId, format) {
|
|
155
|
+
const follow = await commandFor(project, 'grayspar status');
|
|
202
156
|
const stopWatching = () => {
|
|
203
|
-
process.stderr.write(`\nThe deploy goes on without this terminal. To follow it, run\
|
|
204
|
-
process.exit(
|
|
157
|
+
process.stderr.write(`\nThe deploy goes on without this terminal. To follow it, run\n${follow}\n`);
|
|
158
|
+
process.exit(CTRL_C_EXIT_CODE);
|
|
205
159
|
};
|
|
206
160
|
process.once('SIGINT', stopWatching);
|
|
207
161
|
try {
|
|
@@ -211,132 +165,20 @@ async function watchDeployment(api, project, deploymentId, format) {
|
|
|
211
165
|
process.off('SIGINT', stopWatching);
|
|
212
166
|
}
|
|
213
167
|
}
|
|
214
|
-
async function printOutcome(api, project, outcome
|
|
168
|
+
async function printOutcome(api, project, outcome) {
|
|
215
169
|
if (outcome.status === 'live') {
|
|
216
|
-
const live = await dataOrThrow(api.GET('/api/projects/{
|
|
217
|
-
|
|
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`));
|
|
170
|
+
const live = await dataOrThrow(api.GET('/api/projects/{projectId}', { params: { path: { projectId: project.id } } }));
|
|
171
|
+
process.stdout.write(`${receipt(`Deployed ${live.name}`)}\n\n${servicesTable(live)}\n`);
|
|
220
172
|
return;
|
|
221
173
|
}
|
|
222
|
-
if (outcome.status === '
|
|
223
|
-
|
|
174
|
+
if (outcome.status === 'cancelled') {
|
|
175
|
+
await printReplacedDeploy(project);
|
|
224
176
|
return;
|
|
225
177
|
}
|
|
226
|
-
await
|
|
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;
|
|
178
|
+
await printDeployFailure(api, project, outcome);
|
|
337
179
|
}
|
|
338
180
|
export const deployCommand = new Command('deploy')
|
|
339
|
-
.argument('[
|
|
181
|
+
.argument('[folder]', 'deploy only the files under this folder of the project', '.')
|
|
340
182
|
.description('build a project folder and put it online')
|
|
341
183
|
.option('-y, --yes', 'take the plan as shown without asking')
|
|
342
184
|
.option('--name <name>', 'create a new project with this name')
|
|
@@ -345,6 +187,7 @@ export const deployCommand = new Command('deploy')
|
|
|
345
187
|
.addOption(new Option('--env-conflict <winner>', 'which value wins where your .env differs from a saved one').choices(['saved', 'file']))
|
|
346
188
|
.option('-s, --service <name>', 'deploy only this service, repeatable', collectRepeatedValue)
|
|
347
189
|
.option('--path <name=path>', 'where a new service answers, repeatable', collectRepeatedValue)
|
|
190
|
+
.option('--no-path <name>', 'a new service that takes no requests, like a background worker', (name, paths) => collectRepeatedValue(`${name}=${NO_PATH_VALUE}`, paths))
|
|
348
191
|
.option('--dockerfile <name=path>', 'the Dockerfile a new service builds from', collectRepeatedValue)
|
|
349
192
|
.option('--volume <name=path>', 'a folder a service keeps across deploys, repeatable', collectRepeatedValue)
|
|
350
193
|
.action(commandAction((format, command, folder) => {
|
|
@@ -355,7 +198,7 @@ export const deployCommand = new Command('deploy')
|
|
|
355
198
|
name: options.name,
|
|
356
199
|
env: options.env,
|
|
357
200
|
envFile: options.envFile,
|
|
358
|
-
|
|
201
|
+
envConflict: options.envConflict,
|
|
359
202
|
flags: {
|
|
360
203
|
services: options.service ?? [],
|
|
361
204
|
paths: options.path ?? [],
|
|
@@ -1,22 +1,20 @@
|
|
|
1
|
-
/* A variable found beside the service beats one at the repository root, and
|
|
2
|
-
|
|
1
|
+
/* A variable found beside the service beats one at the repository root, and a variable Grayspar sets itself is left
|
|
2
|
+
out wherever it is found. */
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
5
|
import { parseEnv } from 'node:util';
|
|
6
6
|
import { CliError } from '../../lib/errors.js';
|
|
7
7
|
const ENV_FILES_IN_ORDER = ['.env', '.env.production', '.env.local'];
|
|
8
|
-
const EXAMPLE_ENV_FILES = ['.env.example', '.env.sample', '.env.template'];
|
|
9
8
|
export function emptyFoundVariables() {
|
|
10
|
-
return { values: {}, sourceFiles: {}, skippedPlatformVariables: {}
|
|
9
|
+
return { values: {}, sourceFiles: {}, skippedPlatformVariables: {} };
|
|
11
10
|
}
|
|
12
11
|
async function readIfPresent(folder, file) {
|
|
13
|
-
return readFile(
|
|
12
|
+
return readFile(resolve(folder, file), 'utf8').catch(() => null);
|
|
14
13
|
}
|
|
15
14
|
export async function variablesFor(root, serviceFolder, platformVariableNames, envFile) {
|
|
16
15
|
const values = {};
|
|
17
16
|
const sourceFiles = {};
|
|
18
17
|
const skippedPlatformVariables = {};
|
|
19
|
-
const expected = new Set();
|
|
20
18
|
function takeVariables(raw, origin) {
|
|
21
19
|
for (const [name, value] of Object.entries(parseEnv(raw))) {
|
|
22
20
|
if (value === undefined)
|
|
@@ -37,27 +35,13 @@ export async function variablesFor(root, serviceFolder, platformVariableNames, e
|
|
|
37
35
|
continue;
|
|
38
36
|
takeVariables(raw, place === '.' ? file : `${place}/${file}`);
|
|
39
37
|
}
|
|
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
38
|
}
|
|
48
39
|
if (envFile !== undefined) {
|
|
49
|
-
const raw = await readIfPresent(
|
|
40
|
+
const raw = await readIfPresent(process.cwd(), envFile);
|
|
50
41
|
if (raw === null) {
|
|
51
42
|
throw new CliError('no_env_file', `No environment file at ${envFile}.\nGive the path of an existing file with --env-file.`, '--env-file');
|
|
52
43
|
}
|
|
53
44
|
takeVariables(raw, envFile);
|
|
54
45
|
}
|
|
55
|
-
return {
|
|
56
|
-
values,
|
|
57
|
-
sourceFiles,
|
|
58
|
-
skippedPlatformVariables,
|
|
59
|
-
expectedButMissing: [...expected]
|
|
60
|
-
.filter((name) => !(name in values) && !platformVariableNames.includes(name))
|
|
61
|
-
.sort(),
|
|
62
|
-
};
|
|
46
|
+
return { values, sourceFiles, skippedPlatformVariables };
|
|
63
47
|
}
|
|
@@ -1,20 +1,33 @@
|
|
|
1
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. */
|
|
2
|
+
uncommitted or untracked files says it holds changes beyond that commit. Only the deployed folder counts. */
|
|
3
3
|
import { execFile } from 'node:child_process';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
const execFileAsync = promisify(execFile);
|
|
6
6
|
const GIT_TIMEOUT_MS = 5000;
|
|
7
|
+
const NO_COMMIT_YET = '(initial)';
|
|
8
|
+
const DETACHED = '(detached)';
|
|
7
9
|
async function runGit(folder, args) {
|
|
8
10
|
const { stdout } = await execFileAsync('git', args, { cwd: folder, timeout: GIT_TIMEOUT_MS });
|
|
9
11
|
return stdout.trim();
|
|
10
12
|
}
|
|
11
13
|
export async function gitStateOf(folder) {
|
|
12
14
|
try {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
const [status, message] = await Promise.all([
|
|
16
|
+
runGit(folder, ['status', '--porcelain=v2', '--branch', '--', '.']),
|
|
17
|
+
runGit(folder, ['log', '-1', '--format=%s']),
|
|
18
|
+
]);
|
|
19
|
+
const lines = status.split('\n');
|
|
20
|
+
const header = (name) => lines.find((line) => line.startsWith(`# ${name} `))?.slice(`# ${name} `.length);
|
|
21
|
+
const commit = header('branch.oid');
|
|
22
|
+
const branch = header('branch.head');
|
|
23
|
+
if (commit === undefined || commit === NO_COMMIT_YET)
|
|
24
|
+
return null;
|
|
25
|
+
return {
|
|
26
|
+
branch: branch === undefined || branch === DETACHED ? null : branch,
|
|
27
|
+
commit,
|
|
28
|
+
message,
|
|
29
|
+
dirty: lines.some((line) => line !== '' && !line.startsWith('#')),
|
|
30
|
+
};
|
|
18
31
|
}
|
|
19
32
|
catch {
|
|
20
33
|
return null;
|