@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
package/dist/commands/service.js
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
/* A
|
|
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 {
|
|
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}
|
|
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((
|
|
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
|
|
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
|
-
|
|
40
|
-
params: { path: {
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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',
|
|
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(
|
|
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 }),
|
package/dist/commands/volume.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
/* A volume keeps one folder of a service across deploys. It starts at 1 GB and grows
|
|
2
|
-
plan has room; adding or removing one redeploys whatever is live so
|
|
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 {
|
|
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,
|
|
21
|
+
const listed = services.map((service) => ({ service: service.name, volume: service.volume }));
|
|
21
22
|
printResult(format, listed, () => {
|
|
22
|
-
const rows = listed.flatMap(({ service,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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/{
|
|
34
|
-
params: { path: {
|
|
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:
|
|
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
|
|
46
|
-
if (volume
|
|
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
|
-
|
|
50
|
-
|
|
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);
|
package/dist/lib/client.js
CHANGED
|
@@ -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',
|
|
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', `
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
}
|
package/dist/lib/credentials.js
CHANGED
|
@@ -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
|
|
24
|
-
|
|
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 {
|
|
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
|
|
7
|
-
export
|
|
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/{
|
|
13
|
-
params: { path: {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
const log = await dataOrThrow(api.GET('/api/projects/{
|
|
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
|
-
|
|
72
|
+
return log
|
|
56
73
|
.split('\n')
|
|
57
74
|
.filter((line) => line.trim() !== '')
|
|
58
|
-
.slice(-
|
|
75
|
+
.slice(-FAILURE_TAIL_LINES)
|
|
59
76
|
.map((line) => color.dim(line));
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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, {
|
|
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
|
|
119
|
+
await printDeployFailure(api, project, outcome);
|
|
90
120
|
return;
|
|
91
121
|
}
|
|
92
|
-
if (outcome.status === '
|
|
93
|
-
|
|
122
|
+
if (outcome.status === 'cancelled') {
|
|
123
|
+
await printReplacedDeploy(project);
|
|
94
124
|
return;
|
|
95
125
|
}
|
|
96
|
-
|
|
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
|
}
|
package/dist/lib/output.js
CHANGED
|
@@ -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:
|
|
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(
|
|
37
|
+
process.stderr.write(`${failure(known?.message ?? INTERNAL_FAILURE)}\n`);
|
|
34
38
|
}
|
|
35
39
|
process.exit(1);
|
|
36
40
|
}
|
package/dist/lib/project-link.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/dist/lib/prompt.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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/{
|
|
22
|
-
params: { path: {
|
|
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');
|
package/dist/lib/resolve-team.js
CHANGED
|
@@ -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
|
|
17
|
+
throw new CliError('no_such_team', `You aren't in a team called ${namedTeam}.`, '--team');
|
|
18
18
|
}
|
|
19
19
|
return team;
|
|
20
20
|
}
|