@grayspar/cli 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +11 -6
  16. package/dist/commands/login/open-browser.js +2 -2
  17. package/dist/commands/login/sign-in-callback.js +28 -8
  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,18 +1,17 @@
1
- /* A new port redeploys whatever is live. A path or a name is read on every request and a Dockerfile at the next
2
- build, so changing those redeploys nothing. */
1
+ /* A path or a name is read on every request and a Dockerfile at the next build, so a change redeploys nothing. */
3
2
  import { Command } from 'commander';
4
3
  import { dataOrThrow, signedInClient } from '../lib/client.js';
5
4
  import { commandAction } from '../lib/command-helpers.js';
6
5
  import { CliError } from '../lib/errors.js';
7
- import { appliesOnNextDeploy, followRedeploy } from '../lib/follow-deployment.js';
6
+ import { printResult } from '../lib/output.js';
8
7
  import { resolveService } from '../lib/resolve-service.js';
8
+ import { receipt } from '../lib/ui.js';
9
9
  function receiptsFor(service, change) {
10
10
  return [
11
- change.port === undefined ? null : `Set port of ${service} to ${change.port}`,
12
11
  change.pathPrefix === undefined
13
12
  ? null
14
13
  : change.pathPrefix === null
15
- ? `Removed path of ${service}, so only the project's other services reach it`
14
+ ? `Removed path of ${service}`
16
15
  : `Set path of ${service} to ${change.pathPrefix}`,
17
16
  change.name === undefined ? null : `Set name of ${service} to ${change.name}`,
18
17
  change.dockerfile === undefined
@@ -20,46 +19,38 @@ function receiptsFor(service, change) {
20
19
  : change.dockerfile === null
21
20
  ? `Removed Dockerfile of ${service}`
22
21
  : `Set Dockerfile of ${service} to ${change.dockerfile}`,
23
- ].filter((receipt) => receipt !== null);
24
- }
25
- function appliesNoteFor(project, change) {
26
- if (change.port !== undefined)
27
- return appliesOnNextDeploy(project);
28
- return change.dockerfile === undefined ? undefined : 'The next deploy builds with it.';
22
+ ].filter((line) => line !== null);
29
23
  }
30
24
  async function setService(serviceName, change, format) {
31
25
  if (Object.keys(change).length === 0) {
32
- throw new CliError('nothing_to_change', 'Name a change, such as a port or a path.', '--path');
33
- }
34
- if (change.port !== undefined && !Number.isInteger(change.port)) {
35
- throw new CliError('bad_port', 'Give the port as a whole number, like 3000.', '--port');
26
+ throw new CliError('nothing_to_change', 'Name a change, such as a path or a name.', '--path');
36
27
  }
37
28
  const api = await signedInClient();
38
29
  const { project, service } = await resolveService(api, serviceName, '<name>');
39
- const saved = await dataOrThrow(api.PATCH('/api/projects/{id}/services/{serviceId}', {
40
- params: { path: { id: project.id, serviceId: service.id } },
30
+ await dataOrThrow(api.PATCH('/api/projects/{projectId}/services/{serviceId}', {
31
+ params: { path: { projectId: project.id, serviceId: service.id } },
41
32
  body: change,
42
33
  }));
43
- await followRedeploy(api, project, saved.deployment, format, {
44
- receipts: receiptsFor(service.name, change),
45
- whenNothingIsLive: appliesNoteFor(project, change),
46
- });
34
+ const lines = [
35
+ ...receiptsFor(service.name, change).map(receipt),
36
+ ...(change.pathPrefix === null ? ["Only the project's other services reach it."] : []),
37
+ ...(change.dockerfile === undefined ? [] : ['The next deploy builds with it.']),
38
+ ];
39
+ printResult(format, { status: 'ok' }, () => lines.join('\n'));
47
40
  }
48
41
  export const serviceCommand = new Command('service').description('change where a service answers and how it is built');
49
42
  serviceCommand
50
43
  .command('set')
51
44
  .argument('[name]', 'the service to change, left out when the project has one')
52
- .option('--port <port>', 'the port the service listens on')
53
45
  .option('--path <path>', 'where the service answers at the project address')
54
- .option('--no-path', 'reach the service only from the project\u2019s other services')
46
+ .option('--no-path', "reach the service only from the project's other services")
55
47
  .option('--name <name>', 'a new name for the service')
56
48
  .option('--dockerfile <path>', 'build from this Dockerfile, from the root of the project')
57
49
  .option('--no-dockerfile', 'build the way Grayspar found the service again')
58
- .description('change a service\u2019s port, path, name or Dockerfile')
50
+ .description("change a service's path, name or Dockerfile")
59
51
  .action(commandAction((format, command, name) => {
60
52
  const options = command.opts();
61
53
  const change = {
62
- ...(options.port === undefined ? {} : { port: Number(options.port) }),
63
54
  ...(options.path === undefined
64
55
  ? {}
65
56
  : { pathPrefix: options.path === false ? null : options.path }),
@@ -1,11 +1,12 @@
1
- /* A volume keeps one folder of a service across deploys. It starts at 1 GB and grows by itself while the
2
- plan has room; adding or removing one redeploys whatever is live so the change takes effect at once. */
1
+ /* A volume keeps one folder of a service across deploys, and a service keeps one volume. It starts at 1 GB and grows
2
+ by itself while the plan has room; adding or removing one redeploys whatever is live so it takes effect at once. */
3
3
  import { Command } from 'commander';
4
4
  import { dataOrThrow, signedInClient } from '../lib/client.js';
5
5
  import { commandAction, serviceNameOf, serviceOption } from '../lib/command-helpers.js';
6
6
  import { CliError } from '../lib/errors.js';
7
- import { appliesOnNextDeploy, followRedeploy } from '../lib/follow-deployment.js';
7
+ import { followRedeploy, nextDeployNote } from '../lib/follow-deployment.js';
8
8
  import { printResult } from '../lib/output.js';
9
+ import { askYesNo } from '../lib/prompt.js';
9
10
  import { resolveService, resolveServices } from '../lib/resolve-service.js';
10
11
  import { tableOrEmptyNote } from '../lib/ui.js';
11
12
  const BYTES_PER_GIBIBYTE = 1024 ** 3;
@@ -17,37 +18,49 @@ function readableSize(bytes) {
17
18
  async function listVolumes(serviceName, format) {
18
19
  const api = await signedInClient();
19
20
  const { project, services } = await resolveServices(api, serviceName);
20
- const listed = services.map((service) => ({ service: service.name, volumes: service.volumes }));
21
+ const listed = services.map((service) => ({ service: service.name, volume: service.volume }));
21
22
  printResult(format, listed, () => {
22
- const rows = listed.flatMap(({ service, volumes }) => volumes.map((volume) => [
23
- service,
24
- volume.mountPath,
25
- `${readableSize(volume.usedBytes)} of ${readableSize(volume.sizeBytes)}`,
26
- ]));
27
- return tableOrEmptyNote(['SERVICE', 'PATH', 'USED'], rows, `${serviceName ?? project.name} has no volumes.`);
23
+ const rows = listed.flatMap(({ service, volume }) => volume === null
24
+ ? []
25
+ : [
26
+ [
27
+ service,
28
+ volume.mountPath,
29
+ `${readableSize(volume.usedBytes)} of ${readableSize(volume.appliedSizeBytes ?? volume.sizeBytes)}${volume.appliedSizeBytes !== null && volume.appliedSizeBytes < volume.sizeBytes
30
+ ? `, growing to ${readableSize(volume.sizeBytes)}`
31
+ : ''}`,
32
+ ],
33
+ ]);
34
+ return tableOrEmptyNote(['SERVICE', 'PATH', 'USED'], rows, serviceName === undefined
35
+ ? `${project.name} has no volumes.`
36
+ : `${serviceName} has no volume.`);
28
37
  });
29
38
  }
30
39
  async function addVolume(mountPath, serviceName, format) {
31
40
  const api = await signedInClient();
32
41
  const { project, service } = await resolveService(api, serviceName);
33
- const added = await dataOrThrow(api.POST('/api/projects/{id}/services/{serviceId}/volumes', {
34
- params: { path: { id: project.id, serviceId: service.id } },
42
+ const added = await dataOrThrow(api.POST('/api/projects/{projectId}/services/{serviceId}/volumes', {
43
+ params: { path: { projectId: project.id, serviceId: service.id } },
35
44
  body: { mountPath },
36
45
  }));
37
46
  await followRedeploy(api, project, added.deployment, format, {
38
47
  receipts: [`Added volume ${mountPath} to ${service.name}`],
39
- whenNothingIsLive: appliesOnNextDeploy(project),
48
+ whenNothingIsLive: nextDeployNote(project),
40
49
  });
41
50
  }
42
- async function removeVolume(mountPath, serviceName, format) {
51
+ export async function removeVolume(mountPath, serviceName, skipAsking, format) {
43
52
  const api = await signedInClient();
44
53
  const { project, service } = await resolveService(api, serviceName);
45
- const volume = service.volumes.find((candidate) => candidate.mountPath === mountPath);
46
- if (volume === undefined) {
54
+ const { volume } = service;
55
+ if (volume?.mountPath !== mountPath) {
47
56
  throw new CliError('no_such_volume', `${service.name} has no volume at ${mountPath}.`);
48
57
  }
49
- const removed = await dataOrThrow(api.DELETE('/api/projects/{id}/services/{serviceId}/volumes/{volumeId}', {
50
- params: { path: { id: project.id, serviceId: service.id, volumeId: volume.id } },
58
+ if (!skipAsking &&
59
+ !(await askYesNo(`Remove volume ${mountPath} from ${service.name}? Its data is deleted and ${service.name} restarts.`, false, '--yes'))) {
60
+ return;
61
+ }
62
+ const removed = await dataOrThrow(api.DELETE('/api/projects/{projectId}/services/{serviceId}/volumes/{volumeId}', {
63
+ params: { path: { projectId: project.id, serviceId: service.id, volumeId: volume.id } },
51
64
  }));
52
65
  await followRedeploy(api, project, removed.deployment, format, {
53
66
  receipts: [`Removed volume ${mountPath} from ${service.name} and deleted its data`],
@@ -69,5 +82,6 @@ volumeCommand
69
82
  .command('remove')
70
83
  .argument('<path>', 'a folder the service keeps')
71
84
  .addOption(serviceOption('which service keeps it'))
85
+ .option('-y, --yes', 'remove it without asking')
72
86
  .description('stop keeping a folder, and throw its data away')
73
- .action(commandAction((format, command, path) => removeVolume(path, serviceNameOf(command), format)));
87
+ .action(commandAction((format, command, path) => removeVolume(path, serviceNameOf(command), command.opts().yes === true, format)));
package/dist/index.js CHANGED
@@ -16,8 +16,10 @@ import { serviceCommand } from './commands/service.js';
16
16
  import { statusCommand } from './commands/status.js';
17
17
  import { volumeCommand } from './commands/volume.js';
18
18
  import { whoamiCommand } from './commands/whoami.js';
19
+ import { setJsonOutput } from './lib/prompt.js';
19
20
  import { setNamedProject } from './lib/resolve-project.js';
20
21
  import { setNamedTeam } from './lib/resolve-team.js';
22
+ import { routeUsageErrors } from './lib/usage-errors.js';
21
23
  import { cliVersion } from './lib/version.js';
22
24
  const program = new Command()
23
25
  .name('grayspar')
@@ -26,8 +28,10 @@ const program = new Command()
26
28
  .option('--json', 'answer in JSON, for scripts and agents')
27
29
  .option('--team <slug>', 'which of your teams to work in')
28
30
  .option('--project <name>', 'which project to act on, instead of the linked one')
31
+ .option('--no-color', 'print without colour')
29
32
  .hook('preAction', (_program, command) => {
30
- const { team, project } = command.optsWithGlobals();
33
+ const { json, team, project } = command.optsWithGlobals();
34
+ setJsonOutput(json === true);
31
35
  setNamedTeam(team);
32
36
  setNamedProject(project);
33
37
  });
@@ -50,4 +54,5 @@ for (const command of [
50
54
  ]) {
51
55
  program.addCommand(command);
52
56
  }
57
+ routeUsageErrors(program);
53
58
  await program.parseAsync(process.argv);
@@ -5,14 +5,31 @@ import { readCredentials } from './credentials.js';
5
5
  import { CliError } from './errors.js';
6
6
  import { cliVersion } from './version.js';
7
7
  export const DEFAULT_API_URL = 'https://app.grayspar.com';
8
+ const API_TIMEOUT_MS = 2 * 60 * 1000;
8
9
  export async function signedInClient() {
9
10
  const credentials = await readCredentials();
10
11
  if (credentials === null) {
11
- throw new CliError('not_signed_in', 'You are not signed in. Run grayspar login.');
12
+ throw new CliError('not_signed_in', "You aren't signed in. Run grayspar login.");
13
+ }
14
+ const override = process.env.GRAYSPAR_API_URL;
15
+ if (override !== undefined && override !== credentials.apiUrl) {
16
+ throw new CliError('signed_in_elsewhere', `You're signed in to ${credentials.apiUrl}, not ${override}.\nRun grayspar login to sign in there.`);
17
+ }
18
+ return clientAt(credentials.apiUrl, { authorization: `Bearer ${credentials.token}` });
19
+ }
20
+ // It ends the saved session on the Grayspar that issued it, and says whether that Grayspar no longer honours it.
21
+ export async function endSavedSession() {
22
+ const credentials = await readCredentials();
23
+ if (credentials === null)
24
+ return true;
25
+ try {
26
+ const api = clientAt(credentials.apiUrl, { authorization: `Bearer ${credentials.token}` });
27
+ const { response } = await api.DELETE('/api/cli/session');
28
+ return response.ok || response.status === 401;
29
+ }
30
+ catch {
31
+ return false;
12
32
  }
13
- return clientAt(process.env.GRAYSPAR_API_URL ?? credentials.apiUrl, {
14
- authorization: `Bearer ${credentials.token}`,
15
- });
16
33
  }
17
34
  export function publicClient(baseUrl) {
18
35
  return clientAt(baseUrl, {});
@@ -21,9 +38,12 @@ function clientAt(baseUrl, headers) {
21
38
  const client = createClient({
22
39
  baseUrl,
23
40
  headers: { ...headers, 'x-grayspar-cli': cliVersion },
41
+ fetch: (request) => fetch(request, {
42
+ signal: AbortSignal.any([request.signal, AbortSignal.timeout(API_TIMEOUT_MS)]),
43
+ }),
24
44
  });
25
45
  client.use({
26
- onError: () => new CliError('unreachable', `Grayspar could not be reached at ${baseUrl}.\nCheck your connection, then run the command again.`),
46
+ onError: () => new CliError('unreachable', `Can't reach Grayspar at ${baseUrl}.\nCheck your connection, then run the command again.`),
27
47
  });
28
48
  return client;
29
49
  }
@@ -36,10 +56,16 @@ export async function dataOrThrow(pending) {
36
56
  throw new CliError('not_signed_in', 'Your sign-in is no longer valid. Run grayspar login.');
37
57
  }
38
58
  const { error } = answer;
39
- throw new CliError('refused', typeof error === 'object' &&
59
+ const detail = typeof error === 'object' &&
40
60
  error !== null &&
41
61
  'detail' in error &&
42
62
  typeof error.detail === 'string'
43
63
  ? error.detail
44
- : `The server answered ${String(status)}.`);
64
+ : null;
65
+ if (status >= 500) {
66
+ throw detail === null
67
+ ? new CliError('unavailable', "Grayspar didn't answer. Try again in a moment.")
68
+ : new CliError('server_error', detail);
69
+ }
70
+ throw new CliError('refused', detail ?? "Grayspar didn't accept that request.");
45
71
  }
@@ -1,6 +1,6 @@
1
1
  /* The file holds a session token, so the directory and the file are readable only by their owner and
2
2
  the token is never printed, not even by whoami. */
3
- import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
4
4
  import { homedir } from 'node:os';
5
5
  import { dirname, join } from 'node:path';
6
6
  export function credentialsPath() {
@@ -20,8 +20,19 @@ export async function readCredentials() {
20
20
  export async function writeCredentials(credentials) {
21
21
  const path = credentialsPath();
22
22
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
23
- await writeFile(path, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
24
- await chmod(path, 0o600);
23
+ await chmod(dirname(path), 0o700);
24
+ const pending = `${path}.${String(process.pid)}.tmp`;
25
+ await rm(pending, { force: true });
26
+ try {
27
+ await writeFile(pending, `${JSON.stringify(credentials, null, 2)}\n`, {
28
+ mode: 0o600,
29
+ flag: 'wx',
30
+ });
31
+ await rename(pending, path);
32
+ }
33
+ finally {
34
+ await rm(pending, { force: true });
35
+ }
25
36
  }
26
37
  export async function clearCredentials() {
27
38
  await rm(credentialsPath(), { force: true });
@@ -1,43 +1,62 @@
1
1
  import { dataOrThrow } from './client.js';
2
- import { portLines } from './listening.js';
2
+ import { CliError } from './errors.js';
3
3
  import { printResult, startProgress } from './output.js';
4
+ import { commandFor } from './resolve-project.js';
5
+ import { isTransient, realClock, retryTransient } from './retry-transient.js';
4
6
  import { color, failure, receipt } from './ui.js';
5
7
  const POLL_INTERVAL_MS = 2000;
6
- const POLL_CEILING_MS = 12 * 60 * 1000;
7
- export async function awaitDeployment(api, project, deploymentId, { format, label }) {
8
+ const FAILURE_TAIL_LINES = 12;
9
+ export function outcomeOf(deployment) {
10
+ if (deployment.finishedAt === null)
11
+ return null;
12
+ switch (deployment.status) {
13
+ case 'succeeded':
14
+ return { status: 'live', deployment: deployment.id };
15
+ case 'failed': {
16
+ const failed = deployment.revisions.find((revision) => revision.status === 'failed');
17
+ if (failed === undefined) {
18
+ throw new Error(`API reports deployment ${deployment.id} failed without a failed revision`);
19
+ }
20
+ return {
21
+ status: 'failed',
22
+ deployment: deployment.id,
23
+ failedService: failed.service,
24
+ failedServiceId: failed.serviceId,
25
+ failedRevision: failed.id,
26
+ failedBuild: failed.build.id,
27
+ buildFailed: failed.build.status === 'failed',
28
+ };
29
+ }
30
+ case 'cancelled':
31
+ return { status: 'cancelled', deployment: deployment.id };
32
+ case 'queued':
33
+ case 'in-progress':
34
+ return null;
35
+ }
36
+ }
37
+ export async function awaitDeployment(api, project, deploymentId, { format, label }, clock = realClock) {
8
38
  const progress = startProgress(format, label);
9
- const deadline = Date.now() + POLL_CEILING_MS;
10
39
  try {
11
40
  for (;;) {
12
- const current = await dataOrThrow(api.GET('/api/projects/{id}/deployments/{deployId}', {
13
- params: { path: { id: project.id, deployId: deploymentId } },
14
- }));
15
- if (current.status === 'failed' ||
16
- current.status === 'failed-to-start' ||
17
- current.status === 'crashed') {
18
- const failed = current.builds.find((build) => build.status === 'failed' ||
19
- build.status === 'failed-to-start' ||
20
- build.status === 'crashed') ?? current.builds[0];
41
+ const current = await retryTransient((signal) => dataOrThrow(api.GET('/api/projects/{projectId}/deployments/{deploymentId}', {
42
+ params: { path: { projectId: project.id, deploymentId } },
43
+ signal,
44
+ })), clock).catch(async (error) => {
45
+ if (!isTransient(error) && !(error instanceof CliError && error.code === 'server_error')) {
46
+ throw error;
47
+ }
48
+ const follow = await commandFor(project, 'grayspar status');
49
+ throw new CliError(error.code, `${error.code === 'unreachable'
50
+ ? "Can't reach Grayspar"
51
+ : 'Grayspar stopped answering because of an internal error'}, so this terminal can't follow the deployment.\nIt goes on without this terminal. To follow it, run\n${follow}`);
52
+ });
53
+ const outcome = outcomeOf(current);
54
+ if (outcome !== null) {
21
55
  progress.stop();
22
- return {
23
- status: 'failed',
24
- deployment: deploymentId,
25
- failedBuild: failed?.id ?? '',
26
- failedService: failed?.service ?? 'A service',
27
- built: failed !== undefined && failed.status !== 'failed',
28
- };
29
- }
30
- if (current.status === 'live') {
31
- progress.stop();
32
- const listening = current.builds.flatMap(({ service, listening: found }) => found === null ? [] : [{ service, ...found }]);
33
- return { status: 'live', deployment: deploymentId, listening };
56
+ return outcome;
34
57
  }
35
58
  progress.update(current.status === 'queued' ? `${label} (queued)` : label);
36
- if (Date.now() > deadline) {
37
- progress.stop();
38
- return { status: 'pending', deployment: deploymentId };
39
- }
40
- await new Promise((settle) => setTimeout(settle, POLL_INTERVAL_MS));
59
+ await clock.sleep(POLL_INTERVAL_MS);
41
60
  }
42
61
  }
43
62
  catch (error) {
@@ -45,31 +64,42 @@ export async function awaitDeployment(api, project, deploymentId, { format, labe
45
64
  throw error;
46
65
  }
47
66
  }
48
- export async function reportFailure(api, project, outcome, hint) {
49
- const log = await dataOrThrow(api.GET('/api/projects/{id}/deployments/{deployId}/builds/{buildId}/logs', {
50
- params: {
51
- path: { id: project.id, deployId: outcome.deployment, buildId: outcome.failedBuild },
52
- },
67
+ async function buildLogTail(api, project, buildId) {
68
+ const log = await dataOrThrow(api.GET('/api/projects/{projectId}/builds/{buildId}/logs', {
69
+ params: { path: { projectId: project.id, buildId } },
53
70
  parseAs: 'text',
54
71
  }));
55
- const tail = log
72
+ return log
56
73
  .split('\n')
57
74
  .filter((line) => line.trim() !== '')
58
- .slice(-12)
75
+ .slice(-FAILURE_TAIL_LINES)
59
76
  .map((line) => color.dim(line));
60
- const failureStage = outcome.built ? 'failed to start' : 'failed to build';
61
- process.stdout.write(`${failure(`${outcome.failedService} ${failureStage}`)}\n${tail.join('\n')}\n\n` +
62
- (hint === undefined ? '' : `${hint.join('\n')}\n\n`) +
63
- `To read the whole log, run\ngrayspar logs --build --project ${project.name}\n`);
77
+ }
78
+ export async function printDeployFailure(api, project, outcome) {
79
+ if (outcome.buildFailed) {
80
+ const tail = await buildLogTail(api, project, outcome.failedBuild);
81
+ const command = await commandFor(project, 'grayspar logs --build');
82
+ process.stdout.write(`${failure(`${outcome.failedService} failed to build`)}\n${tail.join('\n')}\n\n` +
83
+ `To read the whole log, run\n${command}\n`);
84
+ }
85
+ else {
86
+ const command = await commandFor(project, `grayspar logs --service ${outcome.failedService}`);
87
+ process.stdout.write(`${failure(`${outcome.failedService} failed to start`)}\nTo see why, run\n${command}\n`);
88
+ }
89
+ process.exitCode = 1;
90
+ }
91
+ export async function printReplacedDeploy(project) {
92
+ const command = await commandFor(project, 'grayspar status');
93
+ process.stdout.write(`${failure(`A newer deploy of ${project.name} replaced this one`)}\nTo follow it, run\n${command}\n`);
64
94
  process.exitCode = 1;
65
95
  }
66
- export function appliesOnNextDeploy(project) {
96
+ export function nextDeployNote(project) {
67
97
  return `It applies the next time ${project.name} deploys.`;
68
98
  }
69
99
  export async function followRedeploy(api, project, deployment, format, change) {
70
100
  const receipts = change.receipts.map(receipt);
71
101
  if (deployment === null) {
72
- printResult(format, { status: 'ok', deployment: null }, () => [
102
+ printResult(format, { deployment: null }, () => [
73
103
  ...receipts,
74
104
  ...(change.whenNothingIsLive === undefined ? [] : [change.whenNothingIsLive]),
75
105
  ].join('\n'));
@@ -80,20 +110,18 @@ export async function followRedeploy(api, project, deployment, format, change) {
80
110
  label: `Redeploying ${project.name}…`,
81
111
  });
82
112
  if (format === 'json') {
83
- process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
84
- if (outcome.status === 'failed')
113
+ process.stdout.write(`${JSON.stringify({ deployment: outcome }, null, 2)}\n`);
114
+ if (outcome.status === 'failed' || outcome.status === 'cancelled')
85
115
  process.exitCode = 1;
86
116
  return;
87
117
  }
88
118
  if (outcome.status === 'failed') {
89
- await reportFailure(api, project, outcome);
119
+ await printDeployFailure(api, project, outcome);
90
120
  return;
91
121
  }
92
- if (outcome.status === 'pending') {
93
- process.stdout.write(`Still redeploying. To follow it, run\ngrayspar status --project ${project.name}\n`);
122
+ if (outcome.status === 'cancelled') {
123
+ await printReplacedDeploy(project);
94
124
  return;
95
125
  }
96
- const ports = portLines(outcome.listening);
97
- process.stdout.write(`${[...receipts, receipt(`Redeployed ${project.name}`), ...ports.receipts].join('\n')}\n` +
98
- (ports.warnings.length === 0 ? '' : `\n${ports.warnings.join('\n')}\n`));
126
+ process.stdout.write(`${[...receipts, receipt(`Redeployed ${project.name}`)].join('\n')}\n`);
99
127
  }
@@ -3,6 +3,7 @@
3
3
  import { CliError } from './errors.js';
4
4
  import { isInteractive } from './prompt.js';
5
5
  import { failure, spinner } from './ui.js';
6
+ const INTERNAL_FAILURE = 'The command stopped because of an internal error. Run it again.';
6
7
  export function printText(format, text) {
7
8
  if (format === 'text')
8
9
  process.stdout.write(`${text}\n`);
@@ -25,12 +26,15 @@ export function exitWithError(format, error) {
25
26
  process.stdout.write(`${JSON.stringify({
26
27
  status: 'error',
27
28
  error: known?.code ?? 'unexpected',
28
- message: error instanceof Error ? error.message : String(error),
29
+ message: known?.message ?? INTERNAL_FAILURE,
30
+ ...(known === null
31
+ ? { cause: error instanceof Error ? error.message : String(error) }
32
+ : {}),
29
33
  ...(known?.supplyWith === undefined ? {} : { supply_with: known.supplyWith }),
30
34
  }, null, 2)}\n`);
31
35
  }
32
36
  else {
33
- process.stderr.write(`${failure(error instanceof Error ? error.message : String(error))}\n`);
37
+ process.stderr.write(`${failure(known?.message ?? INTERNAL_FAILURE)}\n`);
34
38
  }
35
39
  process.exit(1);
36
40
  }
@@ -9,7 +9,7 @@ export async function findLink(startFolder) {
9
9
  for (;;) {
10
10
  const found = await readLinkAt(join(folder, LINK_FOLDER, LINK_FILE));
11
11
  if (found !== null)
12
- return found;
12
+ return { ...found, folder };
13
13
  const parent = dirname(folder);
14
14
  if (parent === folder)
15
15
  return null;
@@ -23,10 +23,7 @@ export async function writeLink(folder, link) {
23
23
  await writeFile(join(linkFolder, '.gitignore'), '*\n');
24
24
  }
25
25
  export async function removeLink(folder) {
26
- const linkFolder = join(folder, LINK_FOLDER);
27
- const existed = (await readLinkAt(join(linkFolder, LINK_FILE))) !== null;
28
- await rm(linkFolder, { recursive: true, force: true });
29
- return existed;
26
+ await rm(join(folder, LINK_FOLDER), { recursive: true, force: true });
30
27
  }
31
28
  async function readLinkAt(path) {
32
29
  try {
@@ -4,19 +4,24 @@ import { createPrompt, isEnterKey, useEffect, useKeypress, useState } from '@inq
4
4
  import select from '@inquirer/select';
5
5
  import { CliError } from './errors.js';
6
6
  import { color } from './ui.js';
7
+ let jsonOutput = false;
8
+ export function setJsonOutput(isJson) {
9
+ jsonOutput = isJson;
10
+ }
7
11
  export function isInteractive() {
8
- return Boolean(process.stdin.isTTY && process.stdout.isTTY);
12
+ return !jsonOutput && Boolean(process.stdin.isTTY && process.stdout.isTTY);
9
13
  }
10
14
  function refuseWithoutTerminal(message, flag) {
11
15
  throw new CliError('needs_an_answer', `There is no terminal to answer "${message}".\nGive the answer with ${flag}.`, flag);
12
16
  }
13
- async function cancellable(work, flag) {
17
+ export const CTRL_C_EXIT_CODE = 130;
18
+ export async function exitOnCtrlC(work) {
14
19
  try {
15
20
  return await work();
16
21
  }
17
22
  catch (error) {
18
23
  if (error instanceof Error && error.name === 'ExitPromptError') {
19
- throw new CliError('cancelled', 'Cancelled.', flag);
24
+ process.exit(CTRL_C_EXIT_CODE);
20
25
  }
21
26
  throw error;
22
27
  }
@@ -33,7 +38,7 @@ export async function askChoice(message, label, options, flag) {
33
38
  answer: (text) => text,
34
39
  },
35
40
  };
36
- return cancellable(() => select({ message, choices, theme }), flag);
41
+ return exitOnCtrlC(() => select({ message, choices, theme }));
37
42
  }
38
43
  const textPrompt = createPrompt((config, done) => {
39
44
  const [answered, setAnswered] = useState(false);
@@ -58,8 +63,41 @@ const textPrompt = createPrompt((config, done) => {
58
63
  });
59
64
  return answered ? `${config.label}: ${value}` : `${color.heading(`${config.label}:`)} ${value}`;
60
65
  });
66
+ const yesNoPrompt = createPrompt((config, done) => {
67
+ const [answer, setAnswer] = useState(null);
68
+ const [value, setValue] = useState('');
69
+ useKeypress((key, rl) => {
70
+ if (answer !== null)
71
+ return;
72
+ if (!isEnterKey(key)) {
73
+ setValue(rl.line);
74
+ return;
75
+ }
76
+ const typed = value.trim().toLowerCase();
77
+ const given = typed === ''
78
+ ? config.answerByDefault
79
+ : ['y', 'yes'].includes(typed)
80
+ ? true
81
+ : ['n', 'no'].includes(typed)
82
+ ? false
83
+ : null;
84
+ setValue('');
85
+ if (given === null)
86
+ return;
87
+ setAnswer(given);
88
+ done(given);
89
+ });
90
+ if (answer !== null)
91
+ return `${config.message} ${answer ? 'Yes' : 'No'}`;
92
+ return `${color.heading(config.message)} ${config.answerByDefault ? '(Y/n)' : '(y/N)'} ${value}`;
93
+ });
94
+ export async function askYesNo(message, answerByDefault, flag) {
95
+ if (!isInteractive())
96
+ refuseWithoutTerminal(message, flag);
97
+ return exitOnCtrlC(() => yesNoPrompt({ message, answerByDefault }));
98
+ }
61
99
  export async function askText(label, suggestion, flag) {
62
100
  if (!isInteractive())
63
101
  refuseWithoutTerminal(label, flag);
64
- return cancellable(() => textPrompt({ label, suggestion }), flag);
102
+ return exitOnCtrlC(() => textPrompt({ label, suggestion }));
65
103
  }
@@ -13,13 +13,17 @@ export function projectIsNamed() {
13
13
  export function teamProjects(api, teamId) {
14
14
  return dataOrThrow(api.GET('/api/teams/{teamId}/projects', { params: { path: { teamId } } }));
15
15
  }
16
+ export async function commandFor(project, command) {
17
+ const link = await findLink(process.cwd());
18
+ return link?.projectId === project.id ? command : `${command} --project ${project.name}`;
19
+ }
16
20
  export async function resolveProject(api, { followLink = true } = {}) {
17
21
  if (namedProject !== undefined)
18
22
  return projectNamed(api, namedProject);
19
23
  const link = followLink ? await findLink(process.cwd()) : null;
20
24
  if (link !== null) {
21
- const answer = await api.GET('/api/projects/{id}', {
22
- params: { path: { id: link.projectId } },
25
+ const answer = await api.GET('/api/projects/{projectId}', {
26
+ params: { path: { projectId: link.projectId } },
23
27
  });
24
28
  if (answer.response.status === 404) {
25
29
  throw new CliError('link_outdated', 'The project this folder is linked to no longer exists. Run grayspar link.', '--project');
@@ -14,7 +14,7 @@ export async function resolveTeam(api, options = {}) {
14
14
  if (namedTeam !== undefined) {
15
15
  const team = all.find((candidate) => candidate.slug === namedTeam);
16
16
  if (team === undefined) {
17
- throw new CliError('no_such_team', `You are not in a team called ${namedTeam}.`, '--team');
17
+ throw new CliError('no_such_team', `You aren't in a team called ${namedTeam}.`, '--team');
18
18
  }
19
19
  return team;
20
20
  }