@grayspar/cli 0.0.3 → 0.0.5
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/access.js +3 -3
- package/dist/commands/deploy/deploy.js +2 -2
- package/dist/commands/env.js +2 -2
- package/dist/commands/link.js +2 -2
- package/dist/commands/login/sign-in-callback.js +2 -3
- package/dist/commands/logout.js +1 -1
- package/dist/commands/logs.js +39 -16
- package/dist/commands/project.js +25 -2
- package/dist/commands/restart.js +1 -1
- package/dist/commands/service.js +22 -1
- package/dist/commands/status.js +1 -1
- package/dist/commands/volume.js +4 -4
- package/dist/commands/whoami.js +1 -1
- package/dist/index.js +6 -55
- 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 +5 -4
- package/dist/lib/services-table.js +2 -0
- package/dist/program.js +56 -0
- package/package.json +1 -1
package/dist/commands/access.js
CHANGED
|
@@ -87,17 +87,17 @@ accessCommand
|
|
|
87
87
|
accessCommand
|
|
88
88
|
.command('add')
|
|
89
89
|
.argument('<targets...>', 'email addresses, or @company.example for everyone at a company')
|
|
90
|
-
.description('let
|
|
90
|
+
.description('let people or a whole company open a project')
|
|
91
91
|
.action(commandAction((format, _command, targets) => addAccess(targets, format)));
|
|
92
92
|
accessCommand
|
|
93
93
|
.command('remove')
|
|
94
94
|
.argument('<target>', 'an email address, or @company.example')
|
|
95
|
-
.description(
|
|
95
|
+
.description("remove a person or company from a project's list")
|
|
96
96
|
.action(commandAction((format, _command, target) => removeAccess(target, format)));
|
|
97
97
|
accessCommand
|
|
98
98
|
.command('set')
|
|
99
99
|
.addOption(new Option('--mode <mode>', 'restricted lets in the people and companies on the list, open lets in anyone with the link')
|
|
100
100
|
.choices(['restricted', 'open'])
|
|
101
101
|
.makeOptionMandatory())
|
|
102
|
-
.description('choose whether the list
|
|
102
|
+
.description('choose whether only the list or anyone with the link can open a project')
|
|
103
103
|
.action(commandAction((format, command) => setGeneralAccess(command.opts().mode, format)));
|
|
@@ -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);
|
|
@@ -179,7 +179,7 @@ async function printOutcome(api, project, outcome) {
|
|
|
179
179
|
}
|
|
180
180
|
export const deployCommand = new Command('deploy')
|
|
181
181
|
.argument('[folder]', 'deploy only the files under this folder of the project', '.')
|
|
182
|
-
.description('build a
|
|
182
|
+
.description('build a folder and put it online as a project')
|
|
183
183
|
.option('-y, --yes', 'take the plan as shown without asking')
|
|
184
184
|
.option('--name <name>', 'create a new project with this name')
|
|
185
185
|
.option('--no-env', 'deploy without reading any .env file')
|
package/dist/commands/env.js
CHANGED
|
@@ -61,11 +61,11 @@ envCommand
|
|
|
61
61
|
.command('set')
|
|
62
62
|
.argument('<pairs...>', 'variables written as KEY=VALUE')
|
|
63
63
|
.addOption(serviceOption('which service to set them on'))
|
|
64
|
-
.description('
|
|
64
|
+
.description('set variables on a service')
|
|
65
65
|
.action(commandAction((format, command, pairs) => setVariables(pairs, serviceNameOf(command), format)));
|
|
66
66
|
envCommand
|
|
67
67
|
.command('remove')
|
|
68
68
|
.argument('<keys...>', 'the names of the variables to remove')
|
|
69
69
|
.addOption(serviceOption('which service to change'))
|
|
70
|
-
.description('remove
|
|
70
|
+
.description('remove variables from a service')
|
|
71
71
|
.action(commandAction((format, command, keys) => removeVariables(keys, serviceNameOf(command), format)));
|
package/dist/commands/link.js
CHANGED
|
@@ -21,8 +21,8 @@ async function unlink(format) {
|
|
|
21
21
|
printResult(format, { status: 'ok' }, () => receipt(`Unlinked ${found.folder}`));
|
|
22
22
|
}
|
|
23
23
|
export const linkCommand = new Command('link')
|
|
24
|
-
.description('
|
|
24
|
+
.description('link this folder to a project')
|
|
25
25
|
.action(commandAction((format) => link(format)));
|
|
26
26
|
export const unlinkCommand = new Command('unlink')
|
|
27
|
-
.description('
|
|
27
|
+
.description('unlink this folder from its project')
|
|
28
28
|
.action(commandAction((format) => unlink(format)));
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
/* Listens on a port the machine picks
|
|
2
|
-
|
|
3
|
-
the CLI has traded the code, so what its page says is true. */
|
|
1
|
+
/* Listens on a port the machine picks for one redirect, then closes. The verifier that makes the browser's code usable
|
|
2
|
+
never leaves this process, and the browser hears nothing until the code is traded, so its page tells the truth. */
|
|
4
3
|
import { createServer } from 'node:http';
|
|
5
4
|
function browserPage(mark, markColor, title, detail) {
|
|
6
5
|
return `<!doctype html><meta charset="utf-8"><meta name="color-scheme" content="light dark">
|
package/dist/commands/logout.js
CHANGED
|
@@ -14,5 +14,5 @@ async function logout(format) {
|
|
|
14
14
|
: `${receipt('Signed out on this machine')}\n${warning("Grayspar couldn't be reached, so this sign-in stays valid there. Sign it out under Ways to sign in in the dashboard.")}`);
|
|
15
15
|
}
|
|
16
16
|
export const logoutCommand = new Command('logout')
|
|
17
|
-
.description('
|
|
17
|
+
.description('sign out on this machine')
|
|
18
18
|
.action(commandAction((format) => logout(format)));
|
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) {
|
|
@@ -75,15 +98,15 @@ async function printBuildLogs(api, format) {
|
|
|
75
98
|
}
|
|
76
99
|
}
|
|
77
100
|
export const logsCommand = new Command('logs')
|
|
78
|
-
.description(
|
|
101
|
+
.description("show a service's logs, or with --build its 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/commands/restart.js
CHANGED
|
@@ -16,6 +16,6 @@ async function restart(serviceName, format) {
|
|
|
16
16
|
printResult(format, { status: 'restarting', project: project.name, service: service.name }, () => `Restarting ${service.name}.\nTo follow it, run\n${follow}`);
|
|
17
17
|
}
|
|
18
18
|
export const restartCommand = new Command('restart')
|
|
19
|
-
.description(
|
|
19
|
+
.description("start a fresh copy of a service's running version")
|
|
20
20
|
.addOption(serviceOption('which service to restart'))
|
|
21
21
|
.action(commandAction((format, command) => restart(serviceNameOf(command), format)));
|
package/dist/commands/service.js
CHANGED
|
@@ -4,6 +4,7 @@ import { dataOrThrow, signedInClient } from '../lib/client.js';
|
|
|
4
4
|
import { commandAction } from '../lib/command-helpers.js';
|
|
5
5
|
import { CliError } from '../lib/errors.js';
|
|
6
6
|
import { printResult } from '../lib/output.js';
|
|
7
|
+
import { askText } from '../lib/prompt.js';
|
|
7
8
|
import { resolveService } from '../lib/resolve-service.js';
|
|
8
9
|
import { receipt } from '../lib/ui.js';
|
|
9
10
|
function receiptsFor(service, change) {
|
|
@@ -38,7 +39,21 @@ async function setService(serviceName, change, format) {
|
|
|
38
39
|
];
|
|
39
40
|
printResult(format, { status: 'ok' }, () => lines.join('\n'));
|
|
40
41
|
}
|
|
41
|
-
|
|
42
|
+
async function removeService(serviceName, skipAsking, format) {
|
|
43
|
+
const api = await signedInClient();
|
|
44
|
+
const { project, service } = await resolveService(api, serviceName, '<name>');
|
|
45
|
+
if (!skipAsking) {
|
|
46
|
+
const typed = await askText(`Type ${service.name} to remove it with its volume and variables`, null, '--yes');
|
|
47
|
+
if (typed !== service.name) {
|
|
48
|
+
throw new CliError('name_mismatch', `That isn't ${service.name}. The service stays.`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
await dataOrThrow(api.DELETE('/api/projects/{projectId}/services/{serviceId}', {
|
|
52
|
+
params: { path: { projectId: project.id, serviceId: service.id } },
|
|
53
|
+
}));
|
|
54
|
+
printResult(format, { status: 'removed', service: service.name, project: project.name }, () => `${receipt(`Removed service ${service.name} from ${project.name}`)}\nAny future grayspar deploy will redeploy the ${service.name} service.`);
|
|
55
|
+
}
|
|
56
|
+
export const serviceCommand = new Command('service').description('change where a service answers and how it is built, or remove it');
|
|
42
57
|
serviceCommand
|
|
43
58
|
.command('set')
|
|
44
59
|
.argument('[name]', 'the service to change, left out when the project has one')
|
|
@@ -61,3 +76,9 @@ serviceCommand
|
|
|
61
76
|
};
|
|
62
77
|
return setService(name, change, format);
|
|
63
78
|
}));
|
|
79
|
+
serviceCommand
|
|
80
|
+
.command('remove')
|
|
81
|
+
.argument('<name>', 'the service to remove')
|
|
82
|
+
.option('-y, --yes', 'remove it without asking')
|
|
83
|
+
.description('remove a service with its volume and variables')
|
|
84
|
+
.action(commandAction((format, command, name) => removeService(name, command.opts().yes === true, format)));
|
package/dist/commands/status.js
CHANGED
|
@@ -11,5 +11,5 @@ async function status(format) {
|
|
|
11
11
|
printResult(format, project, () => `${color.heading(project.name)}\n\n${servicesTable(project)}`);
|
|
12
12
|
}
|
|
13
13
|
export const statusCommand = new Command('status')
|
|
14
|
-
.description(
|
|
14
|
+
.description("show each service's address and status")
|
|
15
15
|
.action(commandAction((format) => status(format)));
|
package/dist/commands/volume.js
CHANGED
|
@@ -66,22 +66,22 @@ export async function removeVolume(mountPath, serviceName, skipAsking, format) {
|
|
|
66
66
|
receipts: [`Removed volume ${mountPath} from ${service.name} and deleted its data`],
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
|
-
export const volumeCommand = new Command('volume').description('keep
|
|
69
|
+
export const volumeCommand = new Command('volume').description('keep folders across deploys with volumes');
|
|
70
70
|
volumeCommand
|
|
71
71
|
.command('list', { isDefault: true })
|
|
72
72
|
.addOption(serviceOption('which service to read'))
|
|
73
|
-
.description('list the
|
|
73
|
+
.description('list the volumes of a project')
|
|
74
74
|
.action(commandAction((format, command) => listVolumes(serviceNameOf(command), format)));
|
|
75
75
|
volumeCommand
|
|
76
76
|
.command('add')
|
|
77
77
|
.argument('<path>', 'the folder inside the service to keep, such as /app/data')
|
|
78
78
|
.addOption(serviceOption('which service keeps it'))
|
|
79
|
-
.description('
|
|
79
|
+
.description('add a volume to a service')
|
|
80
80
|
.action(commandAction((format, command, path) => addVolume(path, serviceNameOf(command), format)));
|
|
81
81
|
volumeCommand
|
|
82
82
|
.command('remove')
|
|
83
83
|
.argument('<path>', 'a folder the service keeps')
|
|
84
84
|
.addOption(serviceOption('which service keeps it'))
|
|
85
85
|
.option('-y, --yes', 'remove it without asking')
|
|
86
|
-
.description('
|
|
86
|
+
.description('delete a volume and its data')
|
|
87
87
|
.action(commandAction((format, command, path) => removeVolume(path, serviceNameOf(command), command.opts().yes === true, format)));
|
package/dist/commands/whoami.js
CHANGED
|
@@ -12,5 +12,5 @@ async function whoami(format) {
|
|
|
12
12
|
printResult(format, { ...profile, teams }, () => `Signed in as ${profile.email}\n\n${table(['TEAM', 'SLUG'], teams.map((team) => [team.name, team.slug]))}`);
|
|
13
13
|
}
|
|
14
14
|
export const whoamiCommand = new Command('whoami')
|
|
15
|
-
.description('show
|
|
15
|
+
.description('show which account this machine is signed in with')
|
|
16
16
|
.action(commandAction((format) => whoami(format)));
|
package/dist/index.js
CHANGED
|
@@ -1,58 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
import { linkCommand, unlinkCommand } from './commands/link.js';
|
|
9
|
-
import { loginCommand } from './commands/login/login.js';
|
|
10
|
-
import { logoutCommand } from './commands/logout.js';
|
|
11
|
-
import { logsCommand } from './commands/logs.js';
|
|
12
|
-
import { openPathCommand } from './commands/open-path.js';
|
|
13
|
-
import { projectCommand } from './commands/project.js';
|
|
14
|
-
import { restartCommand } from './commands/restart.js';
|
|
15
|
-
import { serviceCommand } from './commands/service.js';
|
|
16
|
-
import { statusCommand } from './commands/status.js';
|
|
17
|
-
import { volumeCommand } from './commands/volume.js';
|
|
18
|
-
import { whoamiCommand } from './commands/whoami.js';
|
|
19
|
-
import { setJsonOutput } from './lib/prompt.js';
|
|
20
|
-
import { setNamedProject } from './lib/resolve-project.js';
|
|
21
|
-
import { setNamedTeam } from './lib/resolve-team.js';
|
|
22
|
-
import { routeUsageErrors } from './lib/usage-errors.js';
|
|
23
|
-
import { cliVersion } from './lib/version.js';
|
|
24
|
-
const program = new Command()
|
|
25
|
-
.name('grayspar')
|
|
26
|
-
.description('deploy your applications and share them with exactly the people you choose')
|
|
27
|
-
.version(cliVersion, '--version', 'show the version')
|
|
28
|
-
.option('--json', 'answer in JSON, for scripts and agents')
|
|
29
|
-
.option('--team <slug>', 'which of your teams to work in')
|
|
30
|
-
.option('--project <name>', 'which project to act on, instead of the linked one')
|
|
31
|
-
.option('--no-color', 'print without colour')
|
|
32
|
-
.hook('preAction', (_program, command) => {
|
|
33
|
-
const { json, team, project } = command.optsWithGlobals();
|
|
34
|
-
setJsonOutput(json === true);
|
|
35
|
-
setNamedTeam(team);
|
|
36
|
-
setNamedProject(project);
|
|
2
|
+
import { program } from './program.js';
|
|
3
|
+
// A reader such as `head` can leave early, which ends the output and nothing more.
|
|
4
|
+
process.stdout.on('error', (error) => {
|
|
5
|
+
if (error.code === 'EPIPE')
|
|
6
|
+
process.exit(0);
|
|
7
|
+
throw error;
|
|
37
8
|
});
|
|
38
|
-
for (const command of [
|
|
39
|
-
loginCommand,
|
|
40
|
-
logoutCommand,
|
|
41
|
-
whoamiCommand,
|
|
42
|
-
deployCommand,
|
|
43
|
-
projectCommand,
|
|
44
|
-
accessCommand,
|
|
45
|
-
openPathCommand,
|
|
46
|
-
envCommand,
|
|
47
|
-
volumeCommand,
|
|
48
|
-
serviceCommand,
|
|
49
|
-
linkCommand,
|
|
50
|
-
unlinkCommand,
|
|
51
|
-
statusCommand,
|
|
52
|
-
logsCommand,
|
|
53
|
-
restartCommand,
|
|
54
|
-
]) {
|
|
55
|
-
program.addCommand(command);
|
|
56
|
-
}
|
|
57
|
-
routeUsageErrors(program);
|
|
58
9
|
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() !== '')
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
/* A request that never arrived or failed without Grayspar's
|
|
2
|
-
for
|
|
3
|
-
count from the start of the first failing attempt, and each later attempt is cut off when they are up. */
|
|
1
|
+
/* A request that never arrived or failed without Grayspar's answer retries with growing, jittered waits while failures
|
|
2
|
+
run unbroken, for two minutes from the first failing attempt, which also end the last; then the failure stands. */
|
|
4
3
|
import { setTimeout as pause } from 'node:timers/promises';
|
|
5
4
|
import { CliError } from './errors.js';
|
|
6
5
|
const FIRST_DELAY_MS = 1000;
|
|
@@ -10,7 +9,7 @@ export const realClock = { now: () => Date.now(), sleep: (ms) => pause(ms) };
|
|
|
10
9
|
export function isTransient(error) {
|
|
11
10
|
return (error instanceof CliError && (error.code === 'unreachable' || error.code === 'unavailable'));
|
|
12
11
|
}
|
|
13
|
-
export async function retryTransient(work, clock = realClock) {
|
|
12
|
+
export async function retryTransient(work, clock = realClock, onWaiting) {
|
|
14
13
|
let failingSince = null;
|
|
15
14
|
for (let attempt = 0;; attempt += 1) {
|
|
16
15
|
const started = clock.now();
|
|
@@ -22,6 +21,8 @@ export async function retryTransient(work, clock = realClock) {
|
|
|
22
21
|
catch (error) {
|
|
23
22
|
if (!isTransient(error))
|
|
24
23
|
throw error;
|
|
24
|
+
if (failingSince === null)
|
|
25
|
+
onWaiting?.();
|
|
25
26
|
failingSince ??= started;
|
|
26
27
|
if (clock.now() - failingSince >= GIVE_UP_AFTER_MS)
|
|
27
28
|
throw error;
|
|
@@ -5,6 +5,8 @@ const RUNTIME_STATUS = {
|
|
|
5
5
|
running: { label: 'Running', tint: color.ok },
|
|
6
6
|
restarting: { label: 'Restarting', tint: untinted },
|
|
7
7
|
crashed: { label: 'Crashed', tint: color.bad },
|
|
8
|
+
sleeping: { label: 'Sleeping', tint: untinted },
|
|
9
|
+
stopped: { label: 'Stopped', tint: untinted },
|
|
8
10
|
unknown: { label: 'Unknown', tint: untinted },
|
|
9
11
|
failed: { label: 'Failed', tint: color.bad },
|
|
10
12
|
};
|
package/dist/program.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/* Every command is a singular noun and one of four verbs, or a single flat verb. `.claude/rules/cli.md` holds the
|
|
2
|
+
grammar, and each command's own file holds its flags; here are only the flags they all share. */
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { accessCommand } from './commands/access.js';
|
|
5
|
+
import { deployCommand } from './commands/deploy/deploy.js';
|
|
6
|
+
import { envCommand } from './commands/env.js';
|
|
7
|
+
import { linkCommand, unlinkCommand } from './commands/link.js';
|
|
8
|
+
import { loginCommand } from './commands/login/login.js';
|
|
9
|
+
import { logoutCommand } from './commands/logout.js';
|
|
10
|
+
import { logsCommand } from './commands/logs.js';
|
|
11
|
+
import { openPathCommand } from './commands/open-path.js';
|
|
12
|
+
import { projectCommand } from './commands/project.js';
|
|
13
|
+
import { restartCommand } from './commands/restart.js';
|
|
14
|
+
import { serviceCommand } from './commands/service.js';
|
|
15
|
+
import { statusCommand } from './commands/status.js';
|
|
16
|
+
import { volumeCommand } from './commands/volume.js';
|
|
17
|
+
import { whoamiCommand } from './commands/whoami.js';
|
|
18
|
+
import { setJsonOutput } from './lib/prompt.js';
|
|
19
|
+
import { setNamedProject } from './lib/resolve-project.js';
|
|
20
|
+
import { setNamedTeam } from './lib/resolve-team.js';
|
|
21
|
+
import { routeUsageErrors } from './lib/usage-errors.js';
|
|
22
|
+
import { cliVersion } from './lib/version.js';
|
|
23
|
+
export const program = new Command()
|
|
24
|
+
.name('grayspar')
|
|
25
|
+
.description('deploy your applications and share them with exactly the people you choose')
|
|
26
|
+
.version(cliVersion, '--version', 'show the version')
|
|
27
|
+
.option('--json', 'answer in JSON, for scripts and agents')
|
|
28
|
+
.option('--team <slug>', 'which of your teams to work in')
|
|
29
|
+
.option('--project <name>', 'which project to act on, instead of the linked one')
|
|
30
|
+
.option('--no-color', 'print without colour')
|
|
31
|
+
.hook('preAction', (_program, command) => {
|
|
32
|
+
const { json, team, project } = command.optsWithGlobals();
|
|
33
|
+
setJsonOutput(json === true);
|
|
34
|
+
setNamedTeam(team);
|
|
35
|
+
setNamedProject(project);
|
|
36
|
+
});
|
|
37
|
+
for (const command of [
|
|
38
|
+
loginCommand,
|
|
39
|
+
logoutCommand,
|
|
40
|
+
whoamiCommand,
|
|
41
|
+
deployCommand,
|
|
42
|
+
projectCommand,
|
|
43
|
+
accessCommand,
|
|
44
|
+
openPathCommand,
|
|
45
|
+
envCommand,
|
|
46
|
+
volumeCommand,
|
|
47
|
+
serviceCommand,
|
|
48
|
+
linkCommand,
|
|
49
|
+
unlinkCommand,
|
|
50
|
+
statusCommand,
|
|
51
|
+
logsCommand,
|
|
52
|
+
restartCommand,
|
|
53
|
+
]) {
|
|
54
|
+
program.addCommand(command);
|
|
55
|
+
}
|
|
56
|
+
routeUsageErrors(program);
|
package/package.json
CHANGED