@grayspar/cli 0.0.3 → 0.0.4
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/dist/commands/deploy/deploy.js +1 -1
- package/dist/commands/logs.js +38 -15
- package/dist/commands/project.js +25 -2
- package/dist/index.js +6 -0
- package/dist/lib/build-logs.js +36 -0
- package/dist/lib/client.js +1 -1
- package/dist/lib/follow-deployment.js +10 -6
- package/dist/lib/retry-transient.js +3 -1
- package/package.json +1 -1
|
@@ -159,7 +159,7 @@ async function watchDeployment(api, project, deploymentId, format) {
|
|
|
159
159
|
};
|
|
160
160
|
process.once('SIGINT', stopWatching);
|
|
161
161
|
try {
|
|
162
|
-
return await awaitDeployment(api, project, deploymentId, { format, label: '
|
|
162
|
+
return await awaitDeployment(api, project, deploymentId, { format, label: 'Queued…' });
|
|
163
163
|
}
|
|
164
164
|
finally {
|
|
165
165
|
process.off('SIGINT', stopWatching);
|
package/dist/commands/logs.js
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
/* A service's own output is what logs means, so it is the default and the build's log sits behind --build.
|
|
2
|
-
Following asks for
|
|
2
|
+
Following asks for what came after the position the last answer named, every two seconds. */
|
|
3
3
|
import { setTimeout as pause } from 'node:timers/promises';
|
|
4
4
|
import { Command } from 'commander';
|
|
5
|
+
import { buildLogFollower, wholeBuildLog } from '../lib/build-logs.js';
|
|
5
6
|
import { dataOrThrow, signedInClient } from '../lib/client.js';
|
|
6
7
|
import { commandAction, serviceOption } from '../lib/command-helpers.js';
|
|
7
8
|
import { CliError } from '../lib/errors.js';
|
|
9
|
+
import { printNote } from '../lib/output.js';
|
|
8
10
|
import { resolveProject } from '../lib/resolve-project.js';
|
|
9
11
|
import { resolveService } from '../lib/resolve-service.js';
|
|
10
|
-
import { retryTransient } from '../lib/retry-transient.js';
|
|
12
|
+
import { isTransient, realClock, retryTransient } from '../lib/retry-transient.js';
|
|
11
13
|
import { color } from '../lib/ui.js';
|
|
12
14
|
const FOLLOW_INTERVAL_MS = 2_000;
|
|
13
15
|
async function logs(options, format) {
|
|
14
16
|
const api = await signedInClient();
|
|
15
17
|
if (options.build)
|
|
16
|
-
return printBuildLogs(api, format);
|
|
18
|
+
return printBuildLogs(api, format, options.follow);
|
|
17
19
|
const { project, service } = await resolveService(api, options.service);
|
|
18
20
|
const readLogs = (after, signal) => dataOrThrow(api.GET('/api/projects/{projectId}/services/{serviceId}/logs', {
|
|
19
21
|
params: {
|
|
@@ -22,7 +24,11 @@ async function logs(options, format) {
|
|
|
22
24
|
},
|
|
23
25
|
signal,
|
|
24
26
|
}));
|
|
25
|
-
const
|
|
27
|
+
const noteTheDelay = () => printNote(format, 'Logs are delayed. Trying again.');
|
|
28
|
+
const read = (after) => (options.follow
|
|
29
|
+
? retryTransient((signal) => readLogs(after, signal), realClock, noteTheDelay)
|
|
30
|
+
: readLogs(after)).catch(refuseAsDelayed);
|
|
31
|
+
const first = await read();
|
|
26
32
|
if (!options.follow && format === 'text' && first.lines.length === 0) {
|
|
27
33
|
process.stdout.write(`${service.name} has printed nothing yet.\n`);
|
|
28
34
|
return;
|
|
@@ -30,14 +36,19 @@ async function logs(options, format) {
|
|
|
30
36
|
printLogLines(first.lines, format);
|
|
31
37
|
if (!options.follow)
|
|
32
38
|
return;
|
|
33
|
-
let after = first.
|
|
39
|
+
let after = first.next ?? undefined;
|
|
34
40
|
for (;;) {
|
|
35
41
|
await pause(FOLLOW_INTERVAL_MS);
|
|
36
|
-
const
|
|
37
|
-
printLogLines(
|
|
38
|
-
after = next
|
|
42
|
+
const page = await read(after);
|
|
43
|
+
printLogLines(page.lines, format);
|
|
44
|
+
after = page.next ?? undefined;
|
|
39
45
|
}
|
|
40
46
|
}
|
|
47
|
+
function refuseAsDelayed(error) {
|
|
48
|
+
throw isTransient(error)
|
|
49
|
+
? new CliError(error.code, 'Logs are delayed. Try again in a moment.')
|
|
50
|
+
: error;
|
|
51
|
+
}
|
|
41
52
|
function printLogLines(lines, format) {
|
|
42
53
|
for (const line of lines) {
|
|
43
54
|
process.stdout.write(format === 'json'
|
|
@@ -45,7 +56,7 @@ function printLogLines(lines, format) {
|
|
|
45
56
|
: `${color.dim(new Date(line.at).toLocaleTimeString())} ${line.text}\n`);
|
|
46
57
|
}
|
|
47
58
|
}
|
|
48
|
-
async function printBuildLogs(api, format) {
|
|
59
|
+
async function printBuildLogs(api, format, follow) {
|
|
49
60
|
const project = await resolveProject(api);
|
|
50
61
|
const { deployments } = await dataOrThrow(api.GET('/api/projects/{projectId}/deployments', {
|
|
51
62
|
params: { path: { projectId: project.id } },
|
|
@@ -54,13 +65,25 @@ async function printBuildLogs(api, format) {
|
|
|
54
65
|
if (latest === undefined) {
|
|
55
66
|
throw new CliError('never_deployed', `${project.name} hasn't been deployed yet.`);
|
|
56
67
|
}
|
|
68
|
+
if (follow) {
|
|
69
|
+
const readNewLines = buildLogFollower(api, project.id, (service, text) => {
|
|
70
|
+
process.stdout.write(format === 'json' ? `${JSON.stringify({ service, text })}\n` : `${service} ${text}\n`);
|
|
71
|
+
});
|
|
72
|
+
for (;;) {
|
|
73
|
+
const current = await retryTransient((signal) => dataOrThrow(api.GET('/api/projects/{projectId}/deployments/{deploymentId}', {
|
|
74
|
+
params: { path: { projectId: project.id, deploymentId: latest.id } },
|
|
75
|
+
signal,
|
|
76
|
+
})));
|
|
77
|
+
await retryTransient(() => readNewLines(current.revisions));
|
|
78
|
+
if (current.revisions.every((revision) => revision.build.finishedAt !== null))
|
|
79
|
+
return;
|
|
80
|
+
await pause(FOLLOW_INTERVAL_MS);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
57
83
|
const builds = await Promise.all(latest.revisions.map(async (revision) => ({
|
|
58
84
|
service: revision.service,
|
|
59
85
|
status: revision.build.status,
|
|
60
|
-
log: await
|
|
61
|
-
params: { path: { projectId: project.id, buildId: revision.build.id } },
|
|
62
|
-
parseAs: 'text',
|
|
63
|
-
})),
|
|
86
|
+
log: await wholeBuildLog(api, project.id, revision.build.id),
|
|
64
87
|
})));
|
|
65
88
|
if (format === 'json') {
|
|
66
89
|
for (const { service, log } of builds) {
|
|
@@ -78,12 +101,12 @@ export const logsCommand = new Command('logs')
|
|
|
78
101
|
.description('show what a service printed while running, or with --build the last build log')
|
|
79
102
|
.addOption(serviceOption('which service to read'))
|
|
80
103
|
.option('--build', 'show the log from the last build instead')
|
|
81
|
-
.option('-f
|
|
104
|
+
.option('-f', 'keep printing new lines as they arrive')
|
|
82
105
|
.action(commandAction((format, command) => {
|
|
83
106
|
const options = command.opts();
|
|
84
107
|
return logs({
|
|
85
108
|
service: options.service,
|
|
86
109
|
build: options.build === true,
|
|
87
|
-
follow: options.
|
|
110
|
+
follow: options.f === true,
|
|
88
111
|
}, format);
|
|
89
112
|
}));
|
package/dist/commands/project.js
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import { dataOrThrow, signedInClient } from '../lib/client.js';
|
|
4
4
|
import { commandAction } from '../lib/command-helpers.js';
|
|
5
|
+
import { CliError } from '../lib/errors.js';
|
|
5
6
|
import { printResult } from '../lib/output.js';
|
|
6
|
-
import {
|
|
7
|
+
import { findLink, removeLink } from '../lib/project-link.js';
|
|
8
|
+
import { askText } from '../lib/prompt.js';
|
|
9
|
+
import { resolveProject, teamProjects } from '../lib/resolve-project.js';
|
|
7
10
|
import { resolveTeam, teamIsNamed } from '../lib/resolve-team.js';
|
|
8
|
-
import { color, tableOrEmptyNote } from '../lib/ui.js';
|
|
11
|
+
import { color, receipt, tableOrEmptyNote } from '../lib/ui.js';
|
|
9
12
|
async function listProjects(format) {
|
|
10
13
|
const api = await signedInClient();
|
|
11
14
|
const teams = teamIsNamed() ? [await resolveTeam(api)] : await dataOrThrow(api.GET('/api/teams'));
|
|
@@ -15,8 +18,28 @@ async function listProjects(format) {
|
|
|
15
18
|
return tableOrEmptyNote(['TEAM', 'PROJECT', 'URL'], rows, 'No projects yet. Run grayspar deploy in a project folder.', (cell, column) => (column === 2 ? color.link(cell) : cell));
|
|
16
19
|
});
|
|
17
20
|
}
|
|
21
|
+
export async function removeProject(skipAsking, format) {
|
|
22
|
+
const api = await signedInClient();
|
|
23
|
+
const project = await resolveProject(api);
|
|
24
|
+
if (!skipAsking) {
|
|
25
|
+
const typed = await askText(`Type ${project.name} to remove it and everything in it`, null, '--yes');
|
|
26
|
+
if (typed !== project.name) {
|
|
27
|
+
throw new CliError('name_mismatch', `That isn't ${project.name}. The project stays.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
await dataOrThrow(api.DELETE('/api/projects/{projectId}', { params: { path: { projectId: project.id } } }));
|
|
31
|
+
const link = await findLink(process.cwd());
|
|
32
|
+
if (link?.projectId === project.id)
|
|
33
|
+
await removeLink(link.folder);
|
|
34
|
+
printResult(format, { status: 'removed', project: project.name }, () => receipt(`Removed project ${project.name}`));
|
|
35
|
+
}
|
|
18
36
|
export const projectCommand = new Command('project').description('work with your projects');
|
|
19
37
|
projectCommand
|
|
20
38
|
.command('list', { isDefault: true })
|
|
21
39
|
.description('list the projects in your teams')
|
|
22
40
|
.action(commandAction((format) => listProjects(format)));
|
|
41
|
+
projectCommand
|
|
42
|
+
.command('remove')
|
|
43
|
+
.option('-y, --yes', 'remove it without asking')
|
|
44
|
+
.description('remove a project with its services, volumes, variables and deployments')
|
|
45
|
+
.action(commandAction((format, command) => removeProject(command.opts().yes === true, format)));
|
package/dist/index.js
CHANGED
|
@@ -55,4 +55,10 @@ for (const command of [
|
|
|
55
55
|
program.addCommand(command);
|
|
56
56
|
}
|
|
57
57
|
routeUsageErrors(program);
|
|
58
|
+
// A reader such as `head` can leave early, which ends the output and nothing more.
|
|
59
|
+
process.stdout.on('error', (error) => {
|
|
60
|
+
if (error.code === 'EPIPE')
|
|
61
|
+
process.exit(0);
|
|
62
|
+
throw error;
|
|
63
|
+
});
|
|
58
64
|
await program.parseAsync(process.argv);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { dataOrThrow } from './client.js';
|
|
2
|
+
function completeLines(held, text) {
|
|
3
|
+
const parts = `${held}${text}`.split('\n');
|
|
4
|
+
return { lines: parts.slice(0, -1), held: parts.at(-1) ?? '' };
|
|
5
|
+
}
|
|
6
|
+
async function readBuildLog(api, projectId, buildId, after) {
|
|
7
|
+
const page = await dataOrThrow(api.GET('/api/projects/{projectId}/builds/{buildId}/logs', {
|
|
8
|
+
params: { path: { projectId, buildId }, query: after === undefined ? {} : { after } },
|
|
9
|
+
}));
|
|
10
|
+
return { text: page.chunks.map((chunk) => chunk.text).join(''), next: page.next };
|
|
11
|
+
}
|
|
12
|
+
export async function wholeBuildLog(api, projectId, buildId) {
|
|
13
|
+
return (await readBuildLog(api, projectId, buildId)).text;
|
|
14
|
+
}
|
|
15
|
+
export function buildLogFollower(api, projectId, print) {
|
|
16
|
+
const builds = new Map();
|
|
17
|
+
return async (revisions) => {
|
|
18
|
+
for (const { service, build } of revisions) {
|
|
19
|
+
const followed = builds.get(build.id) ?? { held: '', finished: false };
|
|
20
|
+
builds.set(build.id, followed);
|
|
21
|
+
if (build.startedAt === null || followed.finished)
|
|
22
|
+
continue;
|
|
23
|
+
const page = await readBuildLog(api, projectId, build.id, followed.after);
|
|
24
|
+
followed.after = page.next ?? undefined;
|
|
25
|
+
const { lines, held } = completeLines(followed.held, page.text);
|
|
26
|
+
followed.held = held;
|
|
27
|
+
for (const line of lines)
|
|
28
|
+
print(service, line);
|
|
29
|
+
if (build.finishedAt !== null) {
|
|
30
|
+
if (followed.held !== '')
|
|
31
|
+
print(service, followed.held);
|
|
32
|
+
followed.finished = true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
package/dist/lib/client.js
CHANGED
|
@@ -63,7 +63,7 @@ export async function dataOrThrow(pending) {
|
|
|
63
63
|
? error.detail
|
|
64
64
|
: null;
|
|
65
65
|
if (status >= 500) {
|
|
66
|
-
throw detail === null
|
|
66
|
+
throw detail === null || answer.response.headers.has('retry-after')
|
|
67
67
|
? new CliError('unavailable', "Grayspar didn't answer. Try again in a moment.")
|
|
68
68
|
: new CliError('server_error', detail);
|
|
69
69
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { wholeBuildLog } from './build-logs.js';
|
|
1
2
|
import { dataOrThrow } from './client.js';
|
|
2
3
|
import { CliError } from './errors.js';
|
|
3
4
|
import { printResult, startProgress } from './output.js';
|
|
@@ -6,6 +7,11 @@ import { isTransient, realClock, retryTransient } from './retry-transient.js';
|
|
|
6
7
|
import { color, failure, receipt } from './ui.js';
|
|
7
8
|
const POLL_INTERVAL_MS = 2000;
|
|
8
9
|
const FAILURE_TAIL_LINES = 12;
|
|
10
|
+
const STATUS_LINES = {
|
|
11
|
+
queued: 'Queued…',
|
|
12
|
+
building: 'Building…',
|
|
13
|
+
deploying: 'Deploying…',
|
|
14
|
+
};
|
|
9
15
|
export function outcomeOf(deployment) {
|
|
10
16
|
if (deployment.finishedAt === null)
|
|
11
17
|
return null;
|
|
@@ -30,7 +36,8 @@ export function outcomeOf(deployment) {
|
|
|
30
36
|
case 'cancelled':
|
|
31
37
|
return { status: 'cancelled', deployment: deployment.id };
|
|
32
38
|
case 'queued':
|
|
33
|
-
case '
|
|
39
|
+
case 'building':
|
|
40
|
+
case 'deploying':
|
|
34
41
|
return null;
|
|
35
42
|
}
|
|
36
43
|
}
|
|
@@ -55,7 +62,7 @@ export async function awaitDeployment(api, project, deploymentId, { format, labe
|
|
|
55
62
|
progress.stop();
|
|
56
63
|
return outcome;
|
|
57
64
|
}
|
|
58
|
-
progress.update(current.status
|
|
65
|
+
progress.update(STATUS_LINES[current.status] ?? label);
|
|
59
66
|
await clock.sleep(POLL_INTERVAL_MS);
|
|
60
67
|
}
|
|
61
68
|
}
|
|
@@ -65,10 +72,7 @@ export async function awaitDeployment(api, project, deploymentId, { format, labe
|
|
|
65
72
|
}
|
|
66
73
|
}
|
|
67
74
|
async function buildLogTail(api, project, buildId) {
|
|
68
|
-
const log = await
|
|
69
|
-
params: { path: { projectId: project.id, buildId } },
|
|
70
|
-
parseAs: 'text',
|
|
71
|
-
}));
|
|
75
|
+
const log = await wholeBuildLog(api, project.id, buildId);
|
|
72
76
|
return log
|
|
73
77
|
.split('\n')
|
|
74
78
|
.filter((line) => line.trim() !== '')
|
|
@@ -10,7 +10,7 @@ export const realClock = { now: () => Date.now(), sleep: (ms) => pause(ms) };
|
|
|
10
10
|
export function isTransient(error) {
|
|
11
11
|
return (error instanceof CliError && (error.code === 'unreachable' || error.code === 'unavailable'));
|
|
12
12
|
}
|
|
13
|
-
export async function retryTransient(work, clock = realClock) {
|
|
13
|
+
export async function retryTransient(work, clock = realClock, onWaiting) {
|
|
14
14
|
let failingSince = null;
|
|
15
15
|
for (let attempt = 0;; attempt += 1) {
|
|
16
16
|
const started = clock.now();
|
|
@@ -22,6 +22,8 @@ export async function retryTransient(work, clock = realClock) {
|
|
|
22
22
|
catch (error) {
|
|
23
23
|
if (!isTransient(error))
|
|
24
24
|
throw error;
|
|
25
|
+
if (failingSince === null)
|
|
26
|
+
onWaiting?.();
|
|
25
27
|
failingSince ??= started;
|
|
26
28
|
if (clock.now() - failingSince >= GIVE_UP_AFTER_MS)
|
|
27
29
|
throw error;
|
package/package.json
CHANGED