@grayspar/cli 0.0.0-stage → 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.
Files changed (47) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +76 -2
  3. package/dist/commands/access.js +103 -0
  4. package/dist/commands/deploy/archive.js +156 -0
  5. package/dist/commands/deploy/await-discovery.js +17 -0
  6. package/dist/commands/deploy/deploy-destination.js +101 -0
  7. package/dist/commands/deploy/deploy-flags.js +60 -0
  8. package/dist/commands/deploy/deploy-target.js +19 -0
  9. package/dist/commands/deploy/deploy.js +209 -0
  10. package/dist/commands/deploy/env-files.js +47 -0
  11. package/dist/commands/deploy/git-state.js +35 -0
  12. package/dist/commands/deploy/project-name.js +13 -0
  13. package/dist/commands/deploy/submit-deploy.js +127 -0
  14. package/dist/commands/deploy/variables-to-send.js +140 -0
  15. package/dist/commands/env.js +71 -0
  16. package/dist/commands/link.js +28 -0
  17. package/dist/commands/login/challenge.js +5 -0
  18. package/dist/commands/login/login.js +59 -0
  19. package/dist/commands/login/open-browser.js +15 -0
  20. package/dist/commands/login/sign-in-callback.js +37 -0
  21. package/dist/commands/logout.js +18 -0
  22. package/dist/commands/logs.js +89 -0
  23. package/dist/commands/open-path.js +45 -0
  24. package/dist/commands/project.js +22 -0
  25. package/dist/commands/restart.js +21 -0
  26. package/dist/commands/service.js +63 -0
  27. package/dist/commands/status.js +15 -0
  28. package/dist/commands/volume.js +87 -0
  29. package/dist/commands/whoami.js +16 -0
  30. package/dist/index.js +58 -0
  31. package/dist/lib/client.js +71 -0
  32. package/dist/lib/command-helpers.js +25 -0
  33. package/dist/lib/credentials.js +49 -0
  34. package/dist/lib/errors.js +12 -0
  35. package/dist/lib/follow-deployment.js +127 -0
  36. package/dist/lib/output.js +40 -0
  37. package/dist/lib/project-link.js +42 -0
  38. package/dist/lib/prompt.js +103 -0
  39. package/dist/lib/resolve-project.js +60 -0
  40. package/dist/lib/resolve-service.js +29 -0
  41. package/dist/lib/resolve-team.js +31 -0
  42. package/dist/lib/retry-transient.js +32 -0
  43. package/dist/lib/services-table.js +29 -0
  44. package/dist/lib/ui.js +58 -0
  45. package/dist/lib/usage-errors.js +43 -0
  46. package/dist/lib/version.js +12 -0
  47. package/package.json +52 -3
@@ -0,0 +1,71 @@
1
+ /* Saving a variable redeploys whatever is live, so a change is one call and the command waits for it
2
+ the way deploy does. */
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 { 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
+ async function saveVariables(api, project, service, change, format, receipts) {
12
+ const saved = await dataOrThrow(api.PATCH('/api/projects/{projectId}/services/{serviceId}/env', {
13
+ params: { path: { projectId: project.id, serviceId: service.id } },
14
+ body: change,
15
+ }));
16
+ if (saved.deployment === null) {
17
+ printResult(format, { deployment: null }, () => `Nothing changed on ${service.name}.`);
18
+ return;
19
+ }
20
+ await followRedeploy(api, project, saved.deployment, format, { receipts });
21
+ }
22
+ async function setVariables(pairs, serviceName, format) {
23
+ const api = await signedInClient();
24
+ const { project, service } = await resolveService(api, serviceName);
25
+ const variables = {};
26
+ for (const pair of pairs) {
27
+ const equals = pair.indexOf('=');
28
+ if (equals <= 0) {
29
+ throw new CliError('bad_variable', `Write each variable as KEY=VALUE. Got ${pair}.`);
30
+ }
31
+ variables[pair.slice(0, equals)] = pair.slice(equals + 1);
32
+ }
33
+ await saveVariables(api, project, service, { variables }, format, Object.keys(variables).map((key) => `Set variable ${key} on ${service.name}`));
34
+ }
35
+ async function listVariables(serviceName, format) {
36
+ const api = await signedInClient();
37
+ const { project, services } = await resolveServices(api, serviceName);
38
+ const listed = await Promise.all(services.map(async (service) => ({
39
+ service: service.name,
40
+ variables: await dataOrThrow(api.GET('/api/projects/{projectId}/services/{serviceId}/env', {
41
+ params: { path: { projectId: project.id, serviceId: service.id } },
42
+ })),
43
+ })));
44
+ printResult(format, listed, () => {
45
+ const rows = listed.flatMap(({ service, variables }) => variables.map((variable) => [service, variable.name, variable.updatedAt.slice(0, 10)]));
46
+ return tableOrEmptyNote(['SERVICE', 'NAME', 'UPDATED'], rows, `${serviceName ?? project.name} has no variables.`);
47
+ });
48
+ }
49
+ async function removeVariables(keys, serviceName, format) {
50
+ const api = await signedInClient();
51
+ const { project, service } = await resolveService(api, serviceName);
52
+ await saveVariables(api, project, service, { remove: keys }, format, keys.map((key) => `Removed variable ${key} from ${service.name}`));
53
+ }
54
+ export const envCommand = new Command('env').description("set a project's environment variables");
55
+ envCommand
56
+ .command('list', { isDefault: true })
57
+ .addOption(serviceOption('which service to read'))
58
+ .description("list the names of a service's variables")
59
+ .action(commandAction((format, command) => listVariables(serviceNameOf(command), format)));
60
+ envCommand
61
+ .command('set')
62
+ .argument('<pairs...>', 'variables written as KEY=VALUE')
63
+ .addOption(serviceOption('which service to set them on'))
64
+ .description('add or change environment variables')
65
+ .action(commandAction((format, command, pairs) => setVariables(pairs, serviceNameOf(command), format)));
66
+ envCommand
67
+ .command('remove')
68
+ .argument('<keys...>', 'the names of the variables to remove')
69
+ .addOption(serviceOption('which service to change'))
70
+ .description('remove environment variables')
71
+ .action(commandAction((format, command, keys) => removeVariables(keys, serviceNameOf(command), format)));
@@ -0,0 +1,28 @@
1
+ import { Command } from 'commander';
2
+ import { signedInClient } from '../lib/client.js';
3
+ import { commandAction } from '../lib/command-helpers.js';
4
+ import { CliError } from '../lib/errors.js';
5
+ import { printResult } from '../lib/output.js';
6
+ import { findLink, removeLink, writeLink } from '../lib/project-link.js';
7
+ import { resolveProject } from '../lib/resolve-project.js';
8
+ import { receipt } from '../lib/ui.js';
9
+ async function link(format) {
10
+ const api = await signedInClient();
11
+ const project = await resolveProject(api, { followLink: false });
12
+ await writeLink(process.cwd(), { projectId: project.id });
13
+ printResult(format, { status: 'ok', project: project.name }, () => receipt(`Linked this folder to ${project.name}`));
14
+ }
15
+ async function unlink(format) {
16
+ const found = await findLink(process.cwd());
17
+ if (found === null) {
18
+ throw new CliError('not_linked', "This folder isn't linked to a project.");
19
+ }
20
+ await removeLink(found.folder);
21
+ printResult(format, { status: 'ok' }, () => receipt(`Unlinked ${found.folder}`));
22
+ }
23
+ export const linkCommand = new Command('link')
24
+ .description('bind this folder to a project')
25
+ .action(commandAction((format) => link(format)));
26
+ export const unlinkCommand = new Command('unlink')
27
+ .description('unbind this folder from its project')
28
+ .action(commandAction((format) => unlink(format)));
@@ -0,0 +1,5 @@
1
+ /* The browser carries this; the verifier it came from never leaves the process that made it. */
2
+ import { createHash } from 'node:crypto';
3
+ export function challengeFor(verifier) {
4
+ return createHash('sha256').update(verifier).digest('base64url');
5
+ }
@@ -0,0 +1,59 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { hostname } from 'node:os';
3
+ import { Command } from 'commander';
4
+ import { DEFAULT_API_URL, endSavedSession, publicClient } from '../../lib/client.js';
5
+ import { commandAction } from '../../lib/command-helpers.js';
6
+ import { writeCredentials } from '../../lib/credentials.js';
7
+ import { CliError } from '../../lib/errors.js';
8
+ import { printResult, startProgress } from '../../lib/output.js';
9
+ import { color, receipt } from '../../lib/ui.js';
10
+ import { challengeFor } from './challenge.js';
11
+ import { openBrowser } from './open-browser.js';
12
+ import { waitForSignInRedirect } from './sign-in-callback.js';
13
+ const SIGN_IN_WAIT_MS = 5 * 60 * 1000;
14
+ async function login(options) {
15
+ const apiUrl = process.env.GRAYSPAR_API_URL ?? DEFAULT_API_URL;
16
+ const verifier = randomBytes(32).toString('base64url');
17
+ const listener = await waitForSignInRedirect(SIGN_IN_WAIT_MS);
18
+ const url = new URL('/cli', apiUrl);
19
+ url.searchParams.set('port', String(listener.port));
20
+ url.searchParams.set('challenge', challengeFor(verifier));
21
+ process.stderr.write(`${options.format === 'text' ? `${color.heading('Grayspar login')}\n` : ''}${url.toString()}\n`);
22
+ try {
23
+ if (options.browser)
24
+ openBrowser(url.toString());
25
+ const progress = startProgress(options.format, 'Waiting for the browser…');
26
+ try {
27
+ const { code } = await listener.waitForCode;
28
+ if (code === null) {
29
+ throw new CliError('sign_in_timed_out', 'Nothing came back from the browser in time.');
30
+ }
31
+ const { data: session } = await publicClient(apiUrl).POST('/api/cli/token', {
32
+ body: { code, verifier, deviceName: hostname() },
33
+ });
34
+ if (session === undefined) {
35
+ throw new CliError('sign_in_refused', "That sign-in didn't complete. Try again.");
36
+ }
37
+ // The session these credentials replace would otherwise stay valid for months with nothing holding it.
38
+ await endSavedSession();
39
+ await writeCredentials({
40
+ apiUrl,
41
+ token: session.token,
42
+ email: session.email,
43
+ });
44
+ progress.stop();
45
+ printResult(options.format, { status: 'ok', email: session.email }, () => receipt(`Signed in as ${session.email}`));
46
+ }
47
+ catch (error) {
48
+ progress.stop();
49
+ throw error;
50
+ }
51
+ }
52
+ finally {
53
+ listener.close();
54
+ }
55
+ }
56
+ export const loginCommand = new Command('login')
57
+ .description('sign in to Grayspar from this machine')
58
+ .option('--no-browser', 'print the address instead of opening it')
59
+ .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, to open on the same machine. Windows hands the
2
+ 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,37 @@
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
+ // The terminal trades the code for its session after this page has gone out, so the page claims nothing about it.
5
+ const SIGNED_IN_PAGE = `<!doctype html><meta charset="utf-8"><title>Grayspar CLI</title>
6
+ <body style="font:16px/1.5 system-ui;margin:4rem auto;max-width:28rem;text-align:center">
7
+ <h1 style="font-size:1.25rem">Back to your terminal</h1>
8
+ <p>It finishes signing in there. You can close this window.</p>`;
9
+ export async function waitForSignInRedirect(timeoutMs) {
10
+ let settle = () => undefined;
11
+ const waitForCode = new Promise((resolve) => {
12
+ settle = resolve;
13
+ });
14
+ const server = createServer((request, response) => {
15
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1');
16
+ if (url.pathname !== '/callback') {
17
+ response.writeHead(404).end();
18
+ return;
19
+ }
20
+ response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(SIGNED_IN_PAGE);
21
+ settle({ code: url.searchParams.get('code') });
22
+ });
23
+ await new Promise((ready) => server.listen(0, '127.0.0.1', ready));
24
+ const timer = setTimeout(() => settle({ code: null }), timeoutMs);
25
+ const address = server.address();
26
+ if (address === null || typeof address === 'string') {
27
+ throw new Error('sign-in listener did not get a port');
28
+ }
29
+ return {
30
+ port: address.port,
31
+ waitForCode,
32
+ close: () => {
33
+ clearTimeout(timer);
34
+ server.close();
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,18 @@
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 how to end it from the dashboard. */
3
+ import { Command } from 'commander';
4
+ import { endSavedSession } from '../lib/client.js';
5
+ import { commandAction } from '../lib/command-helpers.js';
6
+ import { clearCredentials } from '../lib/credentials.js';
7
+ import { printResult } from '../lib/output.js';
8
+ import { receipt, warning } from '../lib/ui.js';
9
+ async function logout(format) {
10
+ const ended = await endSavedSession();
11
+ await clearCredentials();
12
+ printResult(format, { status: 'ok', revoked: ended }, () => ended
13
+ ? receipt('Signed out')
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
+ }
16
+ export const logoutCommand = new Command('logout')
17
+ .description('forget the saved sign-in on this machine')
18
+ .action(commandAction((format) => logout(format)));
@@ -0,0 +1,89 @@
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 { retryTransient } from '../lib/retry-transient.js';
11
+ import { color } from '../lib/ui.js';
12
+ const FOLLOW_INTERVAL_MS = 2_000;
13
+ async function logs(options, format) {
14
+ const api = await signedInClient();
15
+ if (options.build)
16
+ return printBuildLogs(api, format);
17
+ const { project, service } = await resolveService(api, options.service);
18
+ const readLogs = (after, signal) => dataOrThrow(api.GET('/api/projects/{projectId}/services/{serviceId}/logs', {
19
+ params: {
20
+ path: { projectId: project.id, serviceId: service.id },
21
+ query: after === undefined ? {} : { after },
22
+ },
23
+ signal,
24
+ }));
25
+ const first = await readLogs();
26
+ if (!options.follow && format === 'text' && first.lines.length === 0) {
27
+ process.stdout.write(`${service.name} has printed nothing yet.\n`);
28
+ return;
29
+ }
30
+ printLogLines(first.lines, format);
31
+ if (!options.follow)
32
+ return;
33
+ let after = first.lines.at(-1)?.id;
34
+ for (;;) {
35
+ await pause(FOLLOW_INTERVAL_MS);
36
+ const next = await retryTransient((signal) => readLogs(after, signal));
37
+ printLogLines(next.lines, format);
38
+ after = next.lines.at(-1)?.id ?? after;
39
+ }
40
+ }
41
+ function printLogLines(lines, format) {
42
+ for (const line of lines) {
43
+ process.stdout.write(format === 'json'
44
+ ? `${JSON.stringify(line)}\n`
45
+ : `${color.dim(new Date(line.at).toLocaleTimeString())} ${line.text}\n`);
46
+ }
47
+ }
48
+ async function printBuildLogs(api, format) {
49
+ const project = await resolveProject(api);
50
+ const { deployments } = await dataOrThrow(api.GET('/api/projects/{projectId}/deployments', {
51
+ params: { path: { projectId: project.id } },
52
+ }));
53
+ const latest = deployments[0];
54
+ if (latest === undefined) {
55
+ throw new CliError('never_deployed', `${project.name} hasn't been deployed yet.`);
56
+ }
57
+ const builds = await Promise.all(latest.revisions.map(async (revision) => ({
58
+ service: revision.service,
59
+ status: revision.build.status,
60
+ log: await dataOrThrow(api.GET('/api/projects/{projectId}/builds/{buildId}/logs', {
61
+ params: { path: { projectId: project.id, buildId: revision.build.id } },
62
+ parseAs: 'text',
63
+ })),
64
+ })));
65
+ if (format === 'json') {
66
+ for (const { service, log } of builds) {
67
+ for (const text of log.split('\n').filter((line) => line !== '')) {
68
+ process.stdout.write(`${JSON.stringify({ service, text })}\n`);
69
+ }
70
+ }
71
+ return;
72
+ }
73
+ for (const build of builds) {
74
+ process.stdout.write(`${color.heading(build.service)} ${build.status}\n\n${build.log}\n`);
75
+ }
76
+ }
77
+ export const logsCommand = new Command('logs')
78
+ .description('show what a service printed while running, or with --build the last build log')
79
+ .addOption(serviceOption('which service to read'))
80
+ .option('--build', 'show the log from the last build instead')
81
+ .option('-f, --follow', 'keep printing new lines as they arrive')
82
+ .action(commandAction((format, command) => {
83
+ const options = command.opts();
84
+ return logs({
85
+ service: options.service,
86
+ build: options.build === true,
87
+ follow: options.follow === true,
88
+ }, format);
89
+ }));
@@ -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/{projectId}/open-paths', {
18
+ params: { path: { projectId: 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/{projectId}/open-paths', {
27
+ params: { path: { projectId: 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,21 @@
1
+ /* A restart runs the same container again in place, so the service keeps its volume 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 { commandFor } from '../lib/resolve-project.js';
8
+ import { resolveService } from '../lib/resolve-service.js';
9
+ async function restart(serviceName, format) {
10
+ const api = await signedInClient();
11
+ const { project, service } = await resolveService(api, serviceName);
12
+ await dataOrThrow(api.POST('/api/projects/{projectId}/services/{serviceId}/restart', {
13
+ params: { path: { projectId: project.id, serviceId: service.id } },
14
+ }));
15
+ const follow = await commandFor(project, 'grayspar status');
16
+ printResult(format, { status: 'restarting', project: project.name, service: service.name }, () => `Restarting ${service.name}.\nTo follow it, run\n${follow}`);
17
+ }
18
+ export const restartCommand = new Command('restart')
19
+ .description('run a service again in place, keeping its volume')
20
+ .addOption(serviceOption('which service to restart'))
21
+ .action(commandAction((format, command) => restart(serviceNameOf(command), format)));
@@ -0,0 +1,63 @@
1
+ /* A path or a name is read on every request and a Dockerfile at the next build, so a change redeploys nothing. */
2
+ import { Command } from 'commander';
3
+ import { dataOrThrow, signedInClient } from '../lib/client.js';
4
+ import { commandAction } from '../lib/command-helpers.js';
5
+ import { CliError } from '../lib/errors.js';
6
+ import { printResult } from '../lib/output.js';
7
+ import { resolveService } from '../lib/resolve-service.js';
8
+ import { receipt } from '../lib/ui.js';
9
+ function receiptsFor(service, change) {
10
+ return [
11
+ change.pathPrefix === undefined
12
+ ? null
13
+ : change.pathPrefix === null
14
+ ? `Removed path of ${service}`
15
+ : `Set path of ${service} to ${change.pathPrefix}`,
16
+ change.name === undefined ? null : `Set name of ${service} to ${change.name}`,
17
+ change.dockerfile === undefined
18
+ ? null
19
+ : change.dockerfile === null
20
+ ? `Removed Dockerfile of ${service}`
21
+ : `Set Dockerfile of ${service} to ${change.dockerfile}`,
22
+ ].filter((line) => line !== null);
23
+ }
24
+ async function setService(serviceName, change, format) {
25
+ if (Object.keys(change).length === 0) {
26
+ throw new CliError('nothing_to_change', 'Name a change, such as a path or a name.', '--path');
27
+ }
28
+ const api = await signedInClient();
29
+ const { project, service } = await resolveService(api, serviceName, '<name>');
30
+ await dataOrThrow(api.PATCH('/api/projects/{projectId}/services/{serviceId}', {
31
+ params: { path: { projectId: project.id, serviceId: service.id } },
32
+ body: change,
33
+ }));
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'));
40
+ }
41
+ export const serviceCommand = new Command('service').description('change where a service answers and how it is built');
42
+ serviceCommand
43
+ .command('set')
44
+ .argument('[name]', 'the service to change, left out when the project has one')
45
+ .option('--path <path>', 'where the service answers at the project address')
46
+ .option('--no-path', "reach the service only from the project's other services")
47
+ .option('--name <name>', 'a new name for the service')
48
+ .option('--dockerfile <path>', 'build from this Dockerfile, from the root of the project')
49
+ .option('--no-dockerfile', 'build the way Grayspar found the service again')
50
+ .description("change a service's path, name or Dockerfile")
51
+ .action(commandAction((format, command, name) => {
52
+ const options = command.opts();
53
+ const change = {
54
+ ...(options.path === undefined
55
+ ? {}
56
+ : { pathPrefix: options.path === false ? null : options.path }),
57
+ ...(options.name === undefined ? {} : { name: options.name }),
58
+ ...(options.dockerfile === undefined
59
+ ? {}
60
+ : { dockerfile: options.dockerfile === false ? null : options.dockerfile }),
61
+ };
62
+ return setService(name, change, format);
63
+ }));
@@ -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,87 @@
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
+ 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 { followRedeploy, nextDeployNote } from '../lib/follow-deployment.js';
8
+ import { printResult } from '../lib/output.js';
9
+ import { askYesNo } from '../lib/prompt.js';
10
+ import { resolveService, resolveServices } from '../lib/resolve-service.js';
11
+ import { tableOrEmptyNote } from '../lib/ui.js';
12
+ const BYTES_PER_GIBIBYTE = 1024 ** 3;
13
+ function readableSize(bytes) {
14
+ return bytes >= BYTES_PER_GIBIBYTE
15
+ ? `${(bytes / BYTES_PER_GIBIBYTE).toFixed(1)} GB`
16
+ : `${Math.round(bytes / 1024 ** 2)} MB`;
17
+ }
18
+ async function listVolumes(serviceName, format) {
19
+ const api = await signedInClient();
20
+ const { project, services } = await resolveServices(api, serviceName);
21
+ const listed = services.map((service) => ({ service: service.name, volume: service.volume }));
22
+ printResult(format, listed, () => {
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.`);
37
+ });
38
+ }
39
+ async function addVolume(mountPath, serviceName, format) {
40
+ const api = await signedInClient();
41
+ const { project, service } = await resolveService(api, serviceName);
42
+ const added = await dataOrThrow(api.POST('/api/projects/{projectId}/services/{serviceId}/volumes', {
43
+ params: { path: { projectId: project.id, serviceId: service.id } },
44
+ body: { mountPath },
45
+ }));
46
+ await followRedeploy(api, project, added.deployment, format, {
47
+ receipts: [`Added volume ${mountPath} to ${service.name}`],
48
+ whenNothingIsLive: nextDeployNote(project),
49
+ });
50
+ }
51
+ export async function removeVolume(mountPath, serviceName, skipAsking, format) {
52
+ const api = await signedInClient();
53
+ const { project, service } = await resolveService(api, serviceName);
54
+ const { volume } = service;
55
+ if (volume?.mountPath !== mountPath) {
56
+ throw new CliError('no_such_volume', `${service.name} has no volume at ${mountPath}.`);
57
+ }
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 } },
64
+ }));
65
+ await followRedeploy(api, project, removed.deployment, format, {
66
+ receipts: [`Removed volume ${mountPath} from ${service.name} and deleted its data`],
67
+ });
68
+ }
69
+ export const volumeCommand = new Command('volume').description('keep a folder across deploys');
70
+ volumeCommand
71
+ .command('list', { isDefault: true })
72
+ .addOption(serviceOption('which service to read'))
73
+ .description('list the folders a service keeps')
74
+ .action(commandAction((format, command) => listVolumes(serviceNameOf(command), format)));
75
+ volumeCommand
76
+ .command('add')
77
+ .argument('<path>', 'the folder inside the service to keep, such as /app/data')
78
+ .addOption(serviceOption('which service keeps it'))
79
+ .description('keep a folder across deploys')
80
+ .action(commandAction((format, command, path) => addVolume(path, serviceNameOf(command), format)));
81
+ volumeCommand
82
+ .command('remove')
83
+ .argument('<path>', 'a folder the service keeps')
84
+ .addOption(serviceOption('which service keeps it'))
85
+ .option('-y, --yes', 'remove it without asking')
86
+ .description('stop keeping a folder, and throw its data away')
87
+ .action(commandAction((format, command, path) => removeVolume(path, serviceNameOf(command), command.opts().yes === true, 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)));