@grayspar/cli 0.0.0-stage → 0.0.1
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/LICENSE +202 -0
- package/README.md +76 -2
- package/dist/commands/access.js +103 -0
- package/dist/commands/deploy/archive.js +67 -0
- package/dist/commands/deploy/deploy-flags.js +54 -0
- package/dist/commands/deploy/deploy.js +366 -0
- package/dist/commands/deploy/env-files.js +63 -0
- package/dist/commands/deploy/git-state.js +22 -0
- package/dist/commands/deploy/project-name.js +13 -0
- package/dist/commands/deploy/variables-to-send.js +69 -0
- package/dist/commands/env.js +70 -0
- package/dist/commands/link.js +27 -0
- package/dist/commands/login/challenge.js +5 -0
- package/dist/commands/login/login.js +56 -0
- package/dist/commands/login/open-browser.js +15 -0
- package/dist/commands/login/sign-in-callback.js +36 -0
- package/dist/commands/logout.js +30 -0
- package/dist/commands/logs.js +84 -0
- package/dist/commands/open-path.js +45 -0
- package/dist/commands/project.js +22 -0
- package/dist/commands/restart.js +19 -0
- package/dist/commands/service.js +72 -0
- package/dist/commands/status.js +15 -0
- package/dist/commands/volume.js +73 -0
- package/dist/commands/whoami.js +16 -0
- package/dist/index.js +53 -0
- package/dist/lib/client.js +45 -0
- package/dist/lib/command-helpers.js +25 -0
- package/dist/lib/credentials.js +38 -0
- package/dist/lib/errors.js +12 -0
- package/dist/lib/follow-deployment.js +99 -0
- package/dist/lib/listening.js +24 -0
- package/dist/lib/output.js +36 -0
- package/dist/lib/project-link.js +45 -0
- package/dist/lib/prompt.js +65 -0
- package/dist/lib/resolve-project.js +56 -0
- package/dist/lib/resolve-service.js +29 -0
- package/dist/lib/resolve-team.js +31 -0
- package/dist/lib/services-table.js +28 -0
- package/dist/lib/ui.js +58 -0
- package/dist/lib/version.js +12 -0
- package/package.json +47 -3
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { DEFAULT_API_URL, publicClient } from '../../lib/client.js';
|
|
4
|
+
import { commandAction } from '../../lib/command-helpers.js';
|
|
5
|
+
import { writeCredentials } from '../../lib/credentials.js';
|
|
6
|
+
import { CliError } from '../../lib/errors.js';
|
|
7
|
+
import { printResult, printText, startProgress } from '../../lib/output.js';
|
|
8
|
+
import { color, receipt } from '../../lib/ui.js';
|
|
9
|
+
import { challengeFor } from './challenge.js';
|
|
10
|
+
import { openBrowser } from './open-browser.js';
|
|
11
|
+
import { waitForSignInRedirect } from './sign-in-callback.js';
|
|
12
|
+
const SIGN_IN_WAIT_MS = 5 * 60 * 1000;
|
|
13
|
+
async function login(options) {
|
|
14
|
+
const apiUrl = process.env.GRAYSPAR_API_URL ?? DEFAULT_API_URL;
|
|
15
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
16
|
+
const listener = await waitForSignInRedirect(SIGN_IN_WAIT_MS);
|
|
17
|
+
const url = new URL('/api/cli/authorize', apiUrl);
|
|
18
|
+
url.searchParams.set('port', String(listener.port));
|
|
19
|
+
url.searchParams.set('challenge', challengeFor(verifier));
|
|
20
|
+
printText(options.format, `${color.heading('Grayspar login')}\n${url.toString()}`);
|
|
21
|
+
try {
|
|
22
|
+
if (options.browser)
|
|
23
|
+
openBrowser(url.toString());
|
|
24
|
+
const progress = startProgress(options.format, 'Waiting for the browser…');
|
|
25
|
+
try {
|
|
26
|
+
const { code } = await listener.waitForCode;
|
|
27
|
+
if (code === null) {
|
|
28
|
+
throw new CliError('sign_in_timed_out', 'Nothing came back from the browser in time.');
|
|
29
|
+
}
|
|
30
|
+
const { data: session } = await publicClient(apiUrl).POST('/api/cli/token', {
|
|
31
|
+
body: { code, verifier },
|
|
32
|
+
});
|
|
33
|
+
if (session === undefined) {
|
|
34
|
+
throw new CliError('sign_in_refused', 'That sign-in did not complete. Try again.');
|
|
35
|
+
}
|
|
36
|
+
await writeCredentials({
|
|
37
|
+
apiUrl,
|
|
38
|
+
token: session.token,
|
|
39
|
+
email: session.email,
|
|
40
|
+
});
|
|
41
|
+
progress.stop();
|
|
42
|
+
printResult(options.format, { status: 'ok', email: session.email }, () => receipt(`Signed in as ${session.email}`));
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
progress.stop();
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
listener.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export const loginCommand = new Command('login')
|
|
54
|
+
.description('sign in to Grayspar from this machine')
|
|
55
|
+
.option('--no-browser', 'print the address instead of opening it')
|
|
56
|
+
.action(commandAction((format, command) => login({ format, browser: command.opts().browser })));
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/* Best effort: a machine with no browser prints the address instead, which is also the SSH path. Windows hands
|
|
2
|
+
the address to its URL handler directly, because `start` is a cmd built-in that reads `&` as a command break. */
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
const BROWSER_OPENERS = {
|
|
5
|
+
darwin: (url) => ['open', [url]],
|
|
6
|
+
linux: (url) => ['xdg-open', [url]],
|
|
7
|
+
win32: (url) => ['rundll32', ['url.dll,FileProtocolHandler', url]],
|
|
8
|
+
};
|
|
9
|
+
export function openBrowser(url) {
|
|
10
|
+
const opener = BROWSER_OPENERS[process.platform];
|
|
11
|
+
if (opener === undefined)
|
|
12
|
+
return;
|
|
13
|
+
const [command, args] = opener(url);
|
|
14
|
+
execFile(command, args, () => undefined);
|
|
15
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/* Listens on a port the machine picks, catches exactly one redirect, and closes. The browser carries
|
|
2
|
+
a code; the verifier that makes it usable never leaves this process. */
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
const SIGNED_IN_PAGE = `<!doctype html><meta charset="utf-8"><title>Signed in</title>
|
|
5
|
+
<body style="font:16px/1.5 system-ui;margin:4rem auto;max-width:28rem;text-align:center">
|
|
6
|
+
<h1 style="font-size:1.25rem">You are signed in</h1>
|
|
7
|
+
<p>You can close this window and go back to your terminal.</p>`;
|
|
8
|
+
export async function waitForSignInRedirect(timeoutMs) {
|
|
9
|
+
let settle = () => undefined;
|
|
10
|
+
const waitForCode = new Promise((resolve) => {
|
|
11
|
+
settle = resolve;
|
|
12
|
+
});
|
|
13
|
+
const server = createServer((request, response) => {
|
|
14
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
15
|
+
if (url.pathname !== '/callback') {
|
|
16
|
+
response.writeHead(404).end();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(SIGNED_IN_PAGE);
|
|
20
|
+
settle({ code: url.searchParams.get('code') });
|
|
21
|
+
});
|
|
22
|
+
await new Promise((ready) => server.listen(0, '127.0.0.1', ready));
|
|
23
|
+
const timer = setTimeout(() => settle({ code: null }), timeoutMs);
|
|
24
|
+
const address = server.address();
|
|
25
|
+
if (address === null || typeof address === 'string') {
|
|
26
|
+
throw new Error('The sign-in listener did not get a port.');
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
port: address.port,
|
|
30
|
+
waitForCode,
|
|
31
|
+
close: () => {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
server.close();
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/* Signing out ends the terminal's session on Grayspar, then forgets it here. When Grayspar cannot be reached, it is
|
|
2
|
+
forgotten here all the same, and the person is told it stays valid there until six months pass unused. */
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { signedInClient } from '../lib/client.js';
|
|
5
|
+
import { commandAction } from '../lib/command-helpers.js';
|
|
6
|
+
import { clearCredentials, readCredentials } from '../lib/credentials.js';
|
|
7
|
+
import { printResult } from '../lib/output.js';
|
|
8
|
+
import { receipt, warning } from '../lib/ui.js';
|
|
9
|
+
async function endSessionOnGrayspar() {
|
|
10
|
+
if ((await readCredentials()) === null)
|
|
11
|
+
return true;
|
|
12
|
+
try {
|
|
13
|
+
const api = await signedInClient();
|
|
14
|
+
const { response } = await api.DELETE('/api/cli/session');
|
|
15
|
+
return response.ok || response.status === 401;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function logout(format) {
|
|
22
|
+
const ended = await endSessionOnGrayspar();
|
|
23
|
+
await clearCredentials();
|
|
24
|
+
printResult(format, { status: 'ok', revoked: ended }, () => ended
|
|
25
|
+
? receipt('Signed out')
|
|
26
|
+
: `${receipt('Signed out on this machine')}\n${warning('Grayspar could not be reached, so this sign-in stays valid there until it goes unused for six months.')}`);
|
|
27
|
+
}
|
|
28
|
+
export const logoutCommand = new Command('logout')
|
|
29
|
+
.description('forget the saved sign-in on this machine')
|
|
30
|
+
.action(commandAction((format) => logout(format)));
|
|
@@ -0,0 +1,84 @@
|
|
|
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 the lines after the last one shown, every two seconds. */
|
|
3
|
+
import { setTimeout as pause } from 'node:timers/promises';
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import { dataOrThrow, signedInClient } from '../lib/client.js';
|
|
6
|
+
import { commandAction, serviceOption } from '../lib/command-helpers.js';
|
|
7
|
+
import { CliError } from '../lib/errors.js';
|
|
8
|
+
import { resolveProject } from '../lib/resolve-project.js';
|
|
9
|
+
import { resolveService } from '../lib/resolve-service.js';
|
|
10
|
+
import { color } from '../lib/ui.js';
|
|
11
|
+
const FOLLOW_INTERVAL_MS = 2_000;
|
|
12
|
+
async function logs(options, format) {
|
|
13
|
+
const api = await signedInClient();
|
|
14
|
+
if (options.build)
|
|
15
|
+
return printBuildLogs(api, format);
|
|
16
|
+
const { project, service } = await resolveService(api, options.service);
|
|
17
|
+
const readLogs = (after) => dataOrThrow(api.GET('/api/projects/{id}/services/{serviceId}/logs', {
|
|
18
|
+
params: {
|
|
19
|
+
path: { id: project.id, serviceId: service.id },
|
|
20
|
+
query: after === undefined ? {} : { after },
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
const first = await readLogs();
|
|
24
|
+
if (!options.follow) {
|
|
25
|
+
if (format === 'json')
|
|
26
|
+
process.stdout.write(`${JSON.stringify(first, null, 2)}\n`);
|
|
27
|
+
else if (first.lines.length === 0)
|
|
28
|
+
process.stdout.write(`${service.name} has printed nothing yet.\n`);
|
|
29
|
+
else
|
|
30
|
+
printLogLines(first.lines, format);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
printLogLines(first.lines, format);
|
|
34
|
+
let after = first.lines.at(-1)?.id;
|
|
35
|
+
for (;;) {
|
|
36
|
+
await pause(FOLLOW_INTERVAL_MS);
|
|
37
|
+
const next = await readLogs(after);
|
|
38
|
+
printLogLines(next.lines, format);
|
|
39
|
+
after = next.lines.at(-1)?.id ?? after;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function printLogLines(lines, format) {
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
process.stdout.write(format === 'json'
|
|
45
|
+
? `${JSON.stringify(line)}\n`
|
|
46
|
+
: `${color.dim(new Date(line.at).toLocaleTimeString())} ${line.text}\n`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function printBuildLogs(api, format) {
|
|
50
|
+
const project = await resolveProject(api);
|
|
51
|
+
const deployments = await dataOrThrow(api.GET('/api/projects/{id}/deployments', { params: { path: { id: project.id } } }));
|
|
52
|
+
const latest = deployments[0];
|
|
53
|
+
if (latest === undefined) {
|
|
54
|
+
throw new CliError('never_deployed', `${project.name} has not been deployed yet.`);
|
|
55
|
+
}
|
|
56
|
+
const builds = await Promise.all(latest.builds.map(async (build) => ({
|
|
57
|
+
service: build.service,
|
|
58
|
+
status: build.status,
|
|
59
|
+
log: await dataOrThrow(api.GET('/api/projects/{id}/deployments/{deployId}/builds/{buildId}/logs', {
|
|
60
|
+
params: { path: { id: project.id, deployId: latest.id, buildId: build.id } },
|
|
61
|
+
parseAs: 'text',
|
|
62
|
+
})),
|
|
63
|
+
})));
|
|
64
|
+
if (format === 'json') {
|
|
65
|
+
process.stdout.write(`${JSON.stringify({ deployment: latest.id, builds }, null, 2)}\n`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
for (const build of builds) {
|
|
69
|
+
process.stdout.write(`${color.heading(build.service)} ${build.status}\n\n${build.log}\n`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export const logsCommand = new Command('logs')
|
|
73
|
+
.description('show what a service printed while running, or with --build the last build log')
|
|
74
|
+
.addOption(serviceOption('which service to read'))
|
|
75
|
+
.option('--build', 'show the log from the last build instead')
|
|
76
|
+
.option('-f, --follow', 'keep printing new lines as they arrive')
|
|
77
|
+
.action(commandAction((format, command) => {
|
|
78
|
+
const options = command.opts();
|
|
79
|
+
return logs({
|
|
80
|
+
service: options.service,
|
|
81
|
+
build: options.build === true,
|
|
82
|
+
follow: options.follow === true,
|
|
83
|
+
}, format);
|
|
84
|
+
}));
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/* An open path skips sign-in entirely, so whatever arrives there carries no identity and the app
|
|
2
|
+
behind it checks the sender itself, the way a Stripe or GitHub webhook handler already does. */
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { dataOrThrow, signedInClient } from '../lib/client.js';
|
|
5
|
+
import { commandAction } from '../lib/command-helpers.js';
|
|
6
|
+
import { printResult } from '../lib/output.js';
|
|
7
|
+
import { resolveProject } from '../lib/resolve-project.js';
|
|
8
|
+
import { receipt, tableOrEmptyNote } from '../lib/ui.js';
|
|
9
|
+
async function listOpenPaths(format) {
|
|
10
|
+
const api = await signedInClient();
|
|
11
|
+
const project = await resolveProject(api);
|
|
12
|
+
printResult(format, project.openPaths, () => tableOrEmptyNote(['PATH'], project.openPaths.map((path) => [path]), `Every path of ${project.name} asks for sign-in.`));
|
|
13
|
+
}
|
|
14
|
+
async function addOpenPath(path, format) {
|
|
15
|
+
const api = await signedInClient();
|
|
16
|
+
const project = await resolveProject(api);
|
|
17
|
+
await dataOrThrow(api.POST('/api/projects/{id}/open-paths', {
|
|
18
|
+
params: { path: { id: project.id } },
|
|
19
|
+
body: { path },
|
|
20
|
+
}));
|
|
21
|
+
printResult(format, { status: 'ok', path }, () => `${receipt(`Added open path ${path} to ${project.name}`)}\nAnyone can reach it without signing in.`);
|
|
22
|
+
}
|
|
23
|
+
async function removeOpenPath(path, format) {
|
|
24
|
+
const api = await signedInClient();
|
|
25
|
+
const project = await resolveProject(api);
|
|
26
|
+
await dataOrThrow(api.DELETE('/api/projects/{id}/open-paths', {
|
|
27
|
+
params: { path: { id: project.id }, query: { path } },
|
|
28
|
+
}));
|
|
29
|
+
printResult(format, { status: 'ok', path }, () => receipt(`Removed open path ${path} from ${project.name}`));
|
|
30
|
+
}
|
|
31
|
+
export const openPathCommand = new Command('open-path').description('paths anyone can reach without signing in, such as webhooks');
|
|
32
|
+
openPathCommand
|
|
33
|
+
.command('list', { isDefault: true })
|
|
34
|
+
.description("list a project's open paths")
|
|
35
|
+
.action(commandAction((format) => listOpenPaths(format)));
|
|
36
|
+
openPathCommand
|
|
37
|
+
.command('add')
|
|
38
|
+
.argument('<path>', 'a path such as /webhooks')
|
|
39
|
+
.description('let anyone reach a path and everything below it')
|
|
40
|
+
.action(commandAction((format, _command, path) => addOpenPath(path, format)));
|
|
41
|
+
openPathCommand
|
|
42
|
+
.command('remove')
|
|
43
|
+
.argument('<path>', 'an open path such as /webhooks')
|
|
44
|
+
.description('make a path ask for sign-in again')
|
|
45
|
+
.action(commandAction((format, _command, path) => removeOpenPath(path, format)));
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* Every team's projects are one table with the team as its first column; --team narrows it to that team's rows. */
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { dataOrThrow, signedInClient } from '../lib/client.js';
|
|
4
|
+
import { commandAction } from '../lib/command-helpers.js';
|
|
5
|
+
import { printResult } from '../lib/output.js';
|
|
6
|
+
import { teamProjects } from '../lib/resolve-project.js';
|
|
7
|
+
import { resolveTeam, teamIsNamed } from '../lib/resolve-team.js';
|
|
8
|
+
import { color, tableOrEmptyNote } from '../lib/ui.js';
|
|
9
|
+
async function listProjects(format) {
|
|
10
|
+
const api = await signedInClient();
|
|
11
|
+
const teams = teamIsNamed() ? [await resolveTeam(api)] : await dataOrThrow(api.GET('/api/teams'));
|
|
12
|
+
const groups = await Promise.all(teams.map(async (team) => ({ team, projects: await teamProjects(api, team.id) })));
|
|
13
|
+
printResult(format, groups, () => {
|
|
14
|
+
const rows = groups.flatMap(({ team, projects }) => projects.map((project) => [team.name, project.name, project.url]));
|
|
15
|
+
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
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export const projectCommand = new Command('project').description('work with your projects');
|
|
19
|
+
projectCommand
|
|
20
|
+
.command('list', { isDefault: true })
|
|
21
|
+
.description('list the projects in your teams')
|
|
22
|
+
.action(commandAction((format) => listProjects(format)));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/* A restart runs the same container again in place, so the service keeps its volumes and its address,
|
|
2
|
+
and nothing is built. The worker does it moments after the API accepts it. */
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { dataOrThrow, signedInClient } from '../lib/client.js';
|
|
5
|
+
import { commandAction, serviceNameOf, serviceOption } from '../lib/command-helpers.js';
|
|
6
|
+
import { printResult } from '../lib/output.js';
|
|
7
|
+
import { resolveService } from '../lib/resolve-service.js';
|
|
8
|
+
async function restart(serviceName, format) {
|
|
9
|
+
const api = await signedInClient();
|
|
10
|
+
const { project, service } = await resolveService(api, serviceName);
|
|
11
|
+
await dataOrThrow(api.POST('/api/projects/{id}/services/{serviceId}/restart', {
|
|
12
|
+
params: { path: { id: project.id, serviceId: service.id } },
|
|
13
|
+
}));
|
|
14
|
+
printResult(format, { status: 'restarting', project: project.name, service: service.name }, () => `Restarting ${service.name}.\nTo follow it, run\ngrayspar status --project ${project.name}`);
|
|
15
|
+
}
|
|
16
|
+
export const restartCommand = new Command('restart')
|
|
17
|
+
.description('run a service again in place, keeping its volumes')
|
|
18
|
+
.addOption(serviceOption('which service to restart'))
|
|
19
|
+
.action(commandAction((format, command) => restart(serviceNameOf(command), format)));
|
|
@@ -0,0 +1,72 @@
|
|
|
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. */
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { dataOrThrow, signedInClient } from '../lib/client.js';
|
|
5
|
+
import { commandAction } from '../lib/command-helpers.js';
|
|
6
|
+
import { CliError } from '../lib/errors.js';
|
|
7
|
+
import { appliesOnNextDeploy, followRedeploy } from '../lib/follow-deployment.js';
|
|
8
|
+
import { resolveService } from '../lib/resolve-service.js';
|
|
9
|
+
function receiptsFor(service, change) {
|
|
10
|
+
return [
|
|
11
|
+
change.port === undefined ? null : `Set port of ${service} to ${change.port}`,
|
|
12
|
+
change.pathPrefix === undefined
|
|
13
|
+
? null
|
|
14
|
+
: change.pathPrefix === null
|
|
15
|
+
? `Removed path of ${service}, so only the project's other services reach it`
|
|
16
|
+
: `Set path of ${service} to ${change.pathPrefix}`,
|
|
17
|
+
change.name === undefined ? null : `Set name of ${service} to ${change.name}`,
|
|
18
|
+
change.dockerfile === undefined
|
|
19
|
+
? null
|
|
20
|
+
: change.dockerfile === null
|
|
21
|
+
? `Removed Dockerfile of ${service}`
|
|
22
|
+
: `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.';
|
|
29
|
+
}
|
|
30
|
+
async function setService(serviceName, change, format) {
|
|
31
|
+
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');
|
|
36
|
+
}
|
|
37
|
+
const api = await signedInClient();
|
|
38
|
+
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 } },
|
|
41
|
+
body: change,
|
|
42
|
+
}));
|
|
43
|
+
await followRedeploy(api, project, saved.deployment, format, {
|
|
44
|
+
receipts: receiptsFor(service.name, change),
|
|
45
|
+
whenNothingIsLive: appliesNoteFor(project, change),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
export const serviceCommand = new Command('service').description('change where a service answers and how it is built');
|
|
49
|
+
serviceCommand
|
|
50
|
+
.command('set')
|
|
51
|
+
.argument('[name]', 'the service to change, left out when the project has one')
|
|
52
|
+
.option('--port <port>', 'the port the service listens on')
|
|
53
|
+
.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')
|
|
55
|
+
.option('--name <name>', 'a new name for the service')
|
|
56
|
+
.option('--dockerfile <path>', 'build from this Dockerfile, from the root of the project')
|
|
57
|
+
.option('--no-dockerfile', 'build the way Grayspar found the service again')
|
|
58
|
+
.description('change a service\u2019s port, path, name or Dockerfile')
|
|
59
|
+
.action(commandAction((format, command, name) => {
|
|
60
|
+
const options = command.opts();
|
|
61
|
+
const change = {
|
|
62
|
+
...(options.port === undefined ? {} : { port: Number(options.port) }),
|
|
63
|
+
...(options.path === undefined
|
|
64
|
+
? {}
|
|
65
|
+
: { pathPrefix: options.path === false ? null : options.path }),
|
|
66
|
+
...(options.name === undefined ? {} : { name: options.name }),
|
|
67
|
+
...(options.dockerfile === undefined
|
|
68
|
+
? {}
|
|
69
|
+
: { dockerfile: options.dockerfile === false ? null : options.dockerfile }),
|
|
70
|
+
};
|
|
71
|
+
return setService(name, change, format);
|
|
72
|
+
}));
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { signedInClient } from '../lib/client.js';
|
|
3
|
+
import { commandAction } from '../lib/command-helpers.js';
|
|
4
|
+
import { printResult } from '../lib/output.js';
|
|
5
|
+
import { resolveProject } from '../lib/resolve-project.js';
|
|
6
|
+
import { servicesTable } from '../lib/services-table.js';
|
|
7
|
+
import { color } from '../lib/ui.js';
|
|
8
|
+
async function status(format) {
|
|
9
|
+
const api = await signedInClient();
|
|
10
|
+
const project = await resolveProject(api);
|
|
11
|
+
printResult(format, project, () => `${color.heading(project.name)}\n\n${servicesTable(project)}`);
|
|
12
|
+
}
|
|
13
|
+
export const statusCommand = new Command('status')
|
|
14
|
+
.description('show what a project is doing')
|
|
15
|
+
.action(commandAction((format) => status(format)));
|
|
@@ -0,0 +1,73 @@
|
|
|
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. */
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { dataOrThrow, signedInClient } from '../lib/client.js';
|
|
5
|
+
import { commandAction, serviceNameOf, serviceOption } from '../lib/command-helpers.js';
|
|
6
|
+
import { CliError } from '../lib/errors.js';
|
|
7
|
+
import { appliesOnNextDeploy, followRedeploy } from '../lib/follow-deployment.js';
|
|
8
|
+
import { printResult } from '../lib/output.js';
|
|
9
|
+
import { resolveService, resolveServices } from '../lib/resolve-service.js';
|
|
10
|
+
import { tableOrEmptyNote } from '../lib/ui.js';
|
|
11
|
+
const BYTES_PER_GIBIBYTE = 1024 ** 3;
|
|
12
|
+
function readableSize(bytes) {
|
|
13
|
+
return bytes >= BYTES_PER_GIBIBYTE
|
|
14
|
+
? `${(bytes / BYTES_PER_GIBIBYTE).toFixed(1)} GB`
|
|
15
|
+
: `${Math.round(bytes / 1024 ** 2)} MB`;
|
|
16
|
+
}
|
|
17
|
+
async function listVolumes(serviceName, format) {
|
|
18
|
+
const api = await signedInClient();
|
|
19
|
+
const { project, services } = await resolveServices(api, serviceName);
|
|
20
|
+
const listed = services.map((service) => ({ service: service.name, volumes: service.volumes }));
|
|
21
|
+
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.`);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async function addVolume(mountPath, serviceName, format) {
|
|
31
|
+
const api = await signedInClient();
|
|
32
|
+
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 } },
|
|
35
|
+
body: { mountPath },
|
|
36
|
+
}));
|
|
37
|
+
await followRedeploy(api, project, added.deployment, format, {
|
|
38
|
+
receipts: [`Added volume ${mountPath} to ${service.name}`],
|
|
39
|
+
whenNothingIsLive: appliesOnNextDeploy(project),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
async function removeVolume(mountPath, serviceName, format) {
|
|
43
|
+
const api = await signedInClient();
|
|
44
|
+
const { project, service } = await resolveService(api, serviceName);
|
|
45
|
+
const volume = service.volumes.find((candidate) => candidate.mountPath === mountPath);
|
|
46
|
+
if (volume === undefined) {
|
|
47
|
+
throw new CliError('no_such_volume', `${service.name} has no volume at ${mountPath}.`);
|
|
48
|
+
}
|
|
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 } },
|
|
51
|
+
}));
|
|
52
|
+
await followRedeploy(api, project, removed.deployment, format, {
|
|
53
|
+
receipts: [`Removed volume ${mountPath} from ${service.name} and deleted its data`],
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export const volumeCommand = new Command('volume').description('keep a folder across deploys');
|
|
57
|
+
volumeCommand
|
|
58
|
+
.command('list', { isDefault: true })
|
|
59
|
+
.addOption(serviceOption('which service to read'))
|
|
60
|
+
.description('list the folders a service keeps')
|
|
61
|
+
.action(commandAction((format, command) => listVolumes(serviceNameOf(command), format)));
|
|
62
|
+
volumeCommand
|
|
63
|
+
.command('add')
|
|
64
|
+
.argument('<path>', 'the folder inside the service to keep, such as /app/data')
|
|
65
|
+
.addOption(serviceOption('which service keeps it'))
|
|
66
|
+
.description('keep a folder across deploys')
|
|
67
|
+
.action(commandAction((format, command, path) => addVolume(path, serviceNameOf(command), format)));
|
|
68
|
+
volumeCommand
|
|
69
|
+
.command('remove')
|
|
70
|
+
.argument('<path>', 'a folder the service keeps')
|
|
71
|
+
.addOption(serviceOption('which service keeps it'))
|
|
72
|
+
.description('stop keeping a folder, and throw its data away')
|
|
73
|
+
.action(commandAction((format, command, path) => removeVolume(path, serviceNameOf(command), format)));
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { dataOrThrow, signedInClient } from '../lib/client.js';
|
|
3
|
+
import { commandAction } from '../lib/command-helpers.js';
|
|
4
|
+
import { printResult } from '../lib/output.js';
|
|
5
|
+
import { table } from '../lib/ui.js';
|
|
6
|
+
async function whoami(format) {
|
|
7
|
+
const api = await signedInClient();
|
|
8
|
+
const [profile, teams] = await Promise.all([
|
|
9
|
+
dataOrThrow(api.GET('/api/me')),
|
|
10
|
+
dataOrThrow(api.GET('/api/teams')),
|
|
11
|
+
]);
|
|
12
|
+
printResult(format, { ...profile, teams }, () => `Signed in as ${profile.email}\n\n${table(['TEAM', 'SLUG'], teams.map((team) => [team.name, team.slug]))}`);
|
|
13
|
+
}
|
|
14
|
+
export const whoamiCommand = new Command('whoami')
|
|
15
|
+
.description('show who this machine is signed in as')
|
|
16
|
+
.action(commandAction((format) => whoami(format)));
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* Every command is a singular noun and one of four verbs, or a single flat verb. `.claude/rules/cli.md` holds the
|
|
3
|
+
grammar, and each command's own file holds its flags; here are only the flags they all share. */
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import { accessCommand } from './commands/access.js';
|
|
6
|
+
import { deployCommand } from './commands/deploy/deploy.js';
|
|
7
|
+
import { envCommand } from './commands/env.js';
|
|
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 { setNamedProject } from './lib/resolve-project.js';
|
|
20
|
+
import { setNamedTeam } from './lib/resolve-team.js';
|
|
21
|
+
import { cliVersion } from './lib/version.js';
|
|
22
|
+
const program = new Command()
|
|
23
|
+
.name('grayspar')
|
|
24
|
+
.description('deploy your applications and share them with exactly the people you choose')
|
|
25
|
+
.version(cliVersion, '--version', 'show the version')
|
|
26
|
+
.option('--json', 'answer in JSON, for scripts and agents')
|
|
27
|
+
.option('--team <slug>', 'which of your teams to work in')
|
|
28
|
+
.option('--project <name>', 'which project to act on, instead of the linked one')
|
|
29
|
+
.hook('preAction', (_program, command) => {
|
|
30
|
+
const { team, project } = command.optsWithGlobals();
|
|
31
|
+
setNamedTeam(team);
|
|
32
|
+
setNamedProject(project);
|
|
33
|
+
});
|
|
34
|
+
for (const command of [
|
|
35
|
+
loginCommand,
|
|
36
|
+
logoutCommand,
|
|
37
|
+
whoamiCommand,
|
|
38
|
+
deployCommand,
|
|
39
|
+
projectCommand,
|
|
40
|
+
accessCommand,
|
|
41
|
+
openPathCommand,
|
|
42
|
+
envCommand,
|
|
43
|
+
volumeCommand,
|
|
44
|
+
serviceCommand,
|
|
45
|
+
linkCommand,
|
|
46
|
+
unlinkCommand,
|
|
47
|
+
statusCommand,
|
|
48
|
+
logsCommand,
|
|
49
|
+
restartCommand,
|
|
50
|
+
]) {
|
|
51
|
+
program.addCommand(command);
|
|
52
|
+
}
|
|
53
|
+
await program.parseAsync(process.argv);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/* A 401 always means the same thing here, and so does a request that never reached Grayspar, so each is turned into
|
|
2
|
+
the sentence that fixes it rather than a status code or a network error the person has to look up. */
|
|
3
|
+
import createClient from 'openapi-fetch';
|
|
4
|
+
import { readCredentials } from './credentials.js';
|
|
5
|
+
import { CliError } from './errors.js';
|
|
6
|
+
import { cliVersion } from './version.js';
|
|
7
|
+
export const DEFAULT_API_URL = 'https://app.grayspar.com';
|
|
8
|
+
export async function signedInClient() {
|
|
9
|
+
const credentials = await readCredentials();
|
|
10
|
+
if (credentials === null) {
|
|
11
|
+
throw new CliError('not_signed_in', 'You are not signed in. Run grayspar login.');
|
|
12
|
+
}
|
|
13
|
+
return clientAt(process.env.GRAYSPAR_API_URL ?? credentials.apiUrl, {
|
|
14
|
+
authorization: `Bearer ${credentials.token}`,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export function publicClient(baseUrl) {
|
|
18
|
+
return clientAt(baseUrl, {});
|
|
19
|
+
}
|
|
20
|
+
function clientAt(baseUrl, headers) {
|
|
21
|
+
const client = createClient({
|
|
22
|
+
baseUrl,
|
|
23
|
+
headers: { ...headers, 'x-grayspar-cli': cliVersion },
|
|
24
|
+
});
|
|
25
|
+
client.use({
|
|
26
|
+
onError: () => new CliError('unreachable', `Grayspar could not be reached at ${baseUrl}.\nCheck your connection, then run the command again.`),
|
|
27
|
+
});
|
|
28
|
+
return client;
|
|
29
|
+
}
|
|
30
|
+
export async function dataOrThrow(pending) {
|
|
31
|
+
const answer = await pending;
|
|
32
|
+
if (!('error' in answer))
|
|
33
|
+
return answer.data;
|
|
34
|
+
const { status } = answer.response;
|
|
35
|
+
if (status === 401) {
|
|
36
|
+
throw new CliError('not_signed_in', 'Your sign-in is no longer valid. Run grayspar login.');
|
|
37
|
+
}
|
|
38
|
+
const { error } = answer;
|
|
39
|
+
throw new CliError('refused', typeof error === 'object' &&
|
|
40
|
+
error !== null &&
|
|
41
|
+
'detail' in error &&
|
|
42
|
+
typeof error.detail === 'string'
|
|
43
|
+
? error.detail
|
|
44
|
+
: `The server answered ${String(status)}.`);
|
|
45
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/* What every command's definition shares: the output format the global --json picks, the --service flag, a flag that
|
|
2
|
+
repeats, and the one place a failure is caught and printed in that format. */
|
|
3
|
+
import { Option } from 'commander';
|
|
4
|
+
import { exitWithError } from './output.js';
|
|
5
|
+
export function formatOf(command) {
|
|
6
|
+
return command.optsWithGlobals().json === true ? 'json' : 'text';
|
|
7
|
+
}
|
|
8
|
+
export const serviceOption = (purpose) => new Option('-s, --service <name>', purpose);
|
|
9
|
+
export function serviceNameOf(command) {
|
|
10
|
+
return command.opts().service;
|
|
11
|
+
}
|
|
12
|
+
export function collectRepeatedValue(value, previous) {
|
|
13
|
+
return [...(previous ?? []), value];
|
|
14
|
+
}
|
|
15
|
+
export function commandAction(work) {
|
|
16
|
+
return async function action(...args) {
|
|
17
|
+
const format = formatOf(this);
|
|
18
|
+
try {
|
|
19
|
+
await work(format, this, ...args);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
exitWithError(format, error);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|