@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.
Files changed (38) hide show
  1. package/README.md +3 -3
  2. package/dist/commands/access.js +12 -12
  3. package/dist/commands/deploy/archive.js +124 -35
  4. package/dist/commands/deploy/await-discovery.js +17 -0
  5. package/dist/commands/deploy/deploy-destination.js +101 -0
  6. package/dist/commands/deploy/deploy-flags.js +10 -4
  7. package/dist/commands/deploy/deploy-target.js +19 -0
  8. package/dist/commands/deploy/deploy.js +119 -276
  9. package/dist/commands/deploy/env-files.js +7 -23
  10. package/dist/commands/deploy/git-state.js +19 -6
  11. package/dist/commands/deploy/submit-deploy.js +127 -0
  12. package/dist/commands/deploy/variables-to-send.js +83 -12
  13. package/dist/commands/env.js +13 -12
  14. package/dist/commands/link.js +6 -5
  15. package/dist/commands/login/login.js +9 -6
  16. package/dist/commands/login/open-browser.js +2 -2
  17. package/dist/commands/login/sign-in-callback.js +5 -4
  18. package/dist/commands/logout.js +5 -17
  19. package/dist/commands/logs.js +23 -18
  20. package/dist/commands/open-path.js +4 -4
  21. package/dist/commands/restart.js +7 -5
  22. package/dist/commands/service.js +16 -25
  23. package/dist/commands/volume.js +33 -19
  24. package/dist/index.js +6 -1
  25. package/dist/lib/client.js +33 -7
  26. package/dist/lib/credentials.js +14 -3
  27. package/dist/lib/follow-deployment.js +79 -51
  28. package/dist/lib/output.js +6 -2
  29. package/dist/lib/project-link.js +2 -5
  30. package/dist/lib/prompt.js +43 -5
  31. package/dist/lib/resolve-project.js +6 -2
  32. package/dist/lib/resolve-team.js +1 -1
  33. package/dist/lib/retry-transient.js +32 -0
  34. package/dist/lib/services-table.js +1 -0
  35. package/dist/lib/usage-errors.js +43 -0
  36. package/dist/lib/version.js +1 -1
  37. package/package.json +6 -1
  38. package/dist/lib/listening.js +0 -24
@@ -1,207 +1,161 @@
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';
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, 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';
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, failure, receipt, table, warning } from '../../lib/ui.js';
16
+ import { color, receipt, table, warning } from '../../lib/ui.js';
17
17
  import { archiveOf } from './archive.js';
18
- import { parseDeployFlags, servicesToSend, } from './deploy-flags.js';
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 { projectNameFrom } from './project-name.js';
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 = resolve(folder);
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, new Blob([await archiveOf(root)])).finally(inspecting.stop);
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.rootDirectory, uploaded.platformVariables, options.envFile)
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 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 }),
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
- if (destination.kind === 'new' || !destination.isLinked) {
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 build of deployment.builds) {
57
- for (const path of build.addedVolumes) {
58
- process.stdout.write(`${receipt(`Added volume ${path} to ${build.service}`)}\n`);
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
- process.stdout.write(`${JSON.stringify({ project: project.name, url: project.url, ...outcome }, null, 2)}\n`);
65
- if (outcome.status === 'failed')
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, { 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;
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
- 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;
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
- 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);
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 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) {
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
- process.stdout.write(`${color.heading(`Found ${String(count)} ${count === 1 ? 'service' : 'services'}`)}\n\n` +
190
- `${table(['SERVICE', 'BUILT WITH'], rows)}\n`);
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
- ...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`),
146
+ ...(isWholeProject
147
+ ? uploaded.leftRunning.map((name) => `${name} is no longer in this folder, and keeps running`)
148
+ : []),
196
149
  ];
197
- if (warnings.length > 0)
198
- process.stdout.write(`\n${warnings.map(warning).join('\n')}\n`);
199
- process.stdout.write('\n');
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\ngrayspar status --project ${project.name}\n`);
204
- process.exit(130);
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, { uploaded, serviceFlags }) {
168
+ async function printOutcome(api, project, outcome) {
215
169
  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`));
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 === 'pending') {
223
- process.stdout.write(`Still building. To follow it, run\ngrayspar logs --build --project ${project.name}\n`);
174
+ if (outcome.status === 'cancelled') {
175
+ await printReplacedDeploy(project);
224
176
  return;
225
177
  }
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;
178
+ await printDeployFailure(api, project, outcome);
337
179
  }
338
180
  export const deployCommand = new Command('deploy')
339
- .argument('[directory]', 'the project folder to deploy', '.')
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
- preference: options.envConflict,
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 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. */
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: {}, expectedButMissing: [] };
9
+ return { values: {}, sourceFiles: {}, skippedPlatformVariables: {} };
11
10
  }
12
11
  async function readIfPresent(folder, file) {
13
- return readFile(join(folder, file), 'utf8').catch(() => null);
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(root, envFile);
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 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 !== '' };
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;