@grayspar/cli 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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: 'Deploying…' });
162
+ return await awaitDeployment(api, project, deploymentId, { format, label: 'Queued…' });
163
163
  }
164
164
  finally {
165
165
  process.off('SIGINT', stopWatching);
@@ -41,10 +41,12 @@ async function login(options) {
41
41
  token: session.token,
42
42
  email: session.email,
43
43
  });
44
+ await listener.answerBrowser(true);
44
45
  progress.stop();
45
46
  printResult(options.format, { status: 'ok', email: session.email }, () => receipt(`Signed in as ${session.email}`));
46
47
  }
47
48
  catch (error) {
49
+ await listener.answerBrowser(false);
48
50
  progress.stop();
49
51
  throw error;
50
52
  }
@@ -1,23 +1,32 @@
1
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. */
2
+ a code; the verifier that makes it usable never leaves this process. The browser hears nothing until
3
+ the CLI has traded the code, so what its page says is true. */
3
4
  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>`;
5
+ function browserPage(mark, markColor, title, detail) {
6
+ return `<!doctype html><meta charset="utf-8"><meta name="color-scheme" content="light dark">
7
+ <title>Grayspar CLI</title>
8
+ <style>body{font:16px/1.5 system-ui;margin:0;min-height:100svh;display:grid;place-content:center;text-align:center;background:light-dark(#fff,#0f1010);color:light-dark(#111,#eee)}
9
+ svg{display:block;width:48px;height:48px;margin:0 auto;color:${markColor}}h1{font-size:1.25rem;font-weight:600;margin:1rem 0 .25rem}p{margin:0;opacity:.7}</style>
10
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="${mark}"/></svg>
11
+ <h1>${title}</h1><p>${detail}</p>`;
12
+ }
13
+ const SIGNED_IN_PAGE = browserPage('m8 12 3 3 5-6', '#2f9e5b', 'Signed in successfully', 'You can close this window.');
14
+ const NOT_SIGNED_IN_PAGE = browserPage('m9 9 6 6m0-6-6 6', '#d9534f', "The sign-in didn't complete", 'Run grayspar login again.');
9
15
  export async function waitForSignInRedirect(timeoutMs) {
10
16
  let settle = () => undefined;
11
17
  const waitForCode = new Promise((resolve) => {
12
18
  settle = resolve;
13
19
  });
20
+ let browser = null;
21
+ let redirected = false;
14
22
  const server = createServer((request, response) => {
15
23
  const url = new URL(request.url ?? '/', 'http://127.0.0.1');
16
- if (url.pathname !== '/callback') {
24
+ if (url.pathname !== '/callback' || redirected) {
17
25
  response.writeHead(404).end();
18
26
  return;
19
27
  }
20
- response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(SIGNED_IN_PAGE);
28
+ redirected = true;
29
+ browser = response;
21
30
  settle({ code: url.searchParams.get('code') });
22
31
  });
23
32
  await new Promise((ready) => server.listen(0, '127.0.0.1', ready));
@@ -29,6 +38,16 @@ export async function waitForSignInRedirect(timeoutMs) {
29
38
  return {
30
39
  port: address.port,
31
40
  waitForCode,
41
+ answerBrowser: (signedIn) => new Promise((answered) => {
42
+ if (browser === null) {
43
+ answered();
44
+ return;
45
+ }
46
+ browser
47
+ .writeHead(signedIn ? 200 : 400, { 'content-type': 'text/html; charset=utf-8' })
48
+ .end(signedIn ? SIGNED_IN_PAGE : NOT_SIGNED_IN_PAGE, () => answered());
49
+ browser = null;
50
+ }),
32
51
  close: () => {
33
52
  clearTimeout(timer);
34
53
  server.close();
@@ -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 the lines after the last one shown, every two seconds. */
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 first = await readLogs();
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.lines.at(-1)?.id;
39
+ let after = first.next ?? undefined;
34
40
  for (;;) {
35
41
  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;
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 dataOrThrow(api.GET('/api/projects/{projectId}/builds/{buildId}/logs', {
61
- params: { path: { projectId: project.id, buildId: revision.build.id } },
62
- parseAs: 'text',
63
- })),
86
+ log: await wholeBuildLog(api, project.id, revision.build.id),
64
87
  })));
65
88
  if (format === 'json') {
66
89
  for (const { service, log } of builds) {
@@ -78,12 +101,12 @@ export const logsCommand = new Command('logs')
78
101
  .description('show what a service printed while running, or with --build the last build log')
79
102
  .addOption(serviceOption('which service to read'))
80
103
  .option('--build', 'show the log from the last build instead')
81
- .option('-f, --follow', 'keep printing new lines as they arrive')
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.follow === true,
110
+ follow: options.f === true,
88
111
  }, format);
89
112
  }));
@@ -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 { teamProjects } from '../lib/resolve-project.js';
7
+ import { findLink, removeLink } from '../lib/project-link.js';
8
+ import { askText } from '../lib/prompt.js';
9
+ import { resolveProject, teamProjects } from '../lib/resolve-project.js';
7
10
  import { resolveTeam, teamIsNamed } from '../lib/resolve-team.js';
8
- import { color, tableOrEmptyNote } from '../lib/ui.js';
11
+ import { color, receipt, tableOrEmptyNote } from '../lib/ui.js';
9
12
  async function listProjects(format) {
10
13
  const api = await signedInClient();
11
14
  const teams = teamIsNamed() ? [await resolveTeam(api)] : await dataOrThrow(api.GET('/api/teams'));
@@ -15,8 +18,28 @@ async function listProjects(format) {
15
18
  return tableOrEmptyNote(['TEAM', 'PROJECT', 'URL'], rows, 'No projects yet. Run grayspar deploy in a project folder.', (cell, column) => (column === 2 ? color.link(cell) : cell));
16
19
  });
17
20
  }
21
+ export async function removeProject(skipAsking, format) {
22
+ const api = await signedInClient();
23
+ const project = await resolveProject(api);
24
+ if (!skipAsking) {
25
+ const typed = await askText(`Type ${project.name} to remove it and everything in it`, null, '--yes');
26
+ if (typed !== project.name) {
27
+ throw new CliError('name_mismatch', `That isn't ${project.name}. The project stays.`);
28
+ }
29
+ }
30
+ await dataOrThrow(api.DELETE('/api/projects/{projectId}', { params: { path: { projectId: project.id } } }));
31
+ const link = await findLink(process.cwd());
32
+ if (link?.projectId === project.id)
33
+ await removeLink(link.folder);
34
+ printResult(format, { status: 'removed', project: project.name }, () => receipt(`Removed project ${project.name}`));
35
+ }
18
36
  export const projectCommand = new Command('project').description('work with your projects');
19
37
  projectCommand
20
38
  .command('list', { isDefault: true })
21
39
  .description('list the projects in your teams')
22
40
  .action(commandAction((format) => listProjects(format)));
41
+ projectCommand
42
+ .command('remove')
43
+ .option('-y, --yes', 'remove it without asking')
44
+ .description('remove a project with its services, volumes, variables and deployments')
45
+ .action(commandAction((format, command) => removeProject(command.opts().yes === true, format)));
package/dist/index.js CHANGED
@@ -55,4 +55,10 @@ for (const command of [
55
55
  program.addCommand(command);
56
56
  }
57
57
  routeUsageErrors(program);
58
+ // A reader such as `head` can leave early, which ends the output and nothing more.
59
+ process.stdout.on('error', (error) => {
60
+ if (error.code === 'EPIPE')
61
+ process.exit(0);
62
+ throw error;
63
+ });
58
64
  await program.parseAsync(process.argv);
@@ -0,0 +1,36 @@
1
+ import { dataOrThrow } from './client.js';
2
+ function completeLines(held, text) {
3
+ const parts = `${held}${text}`.split('\n');
4
+ return { lines: parts.slice(0, -1), held: parts.at(-1) ?? '' };
5
+ }
6
+ async function readBuildLog(api, projectId, buildId, after) {
7
+ const page = await dataOrThrow(api.GET('/api/projects/{projectId}/builds/{buildId}/logs', {
8
+ params: { path: { projectId, buildId }, query: after === undefined ? {} : { after } },
9
+ }));
10
+ return { text: page.chunks.map((chunk) => chunk.text).join(''), next: page.next };
11
+ }
12
+ export async function wholeBuildLog(api, projectId, buildId) {
13
+ return (await readBuildLog(api, projectId, buildId)).text;
14
+ }
15
+ export function buildLogFollower(api, projectId, print) {
16
+ const builds = new Map();
17
+ return async (revisions) => {
18
+ for (const { service, build } of revisions) {
19
+ const followed = builds.get(build.id) ?? { held: '', finished: false };
20
+ builds.set(build.id, followed);
21
+ if (build.startedAt === null || followed.finished)
22
+ continue;
23
+ const page = await readBuildLog(api, projectId, build.id, followed.after);
24
+ followed.after = page.next ?? undefined;
25
+ const { lines, held } = completeLines(followed.held, page.text);
26
+ followed.held = held;
27
+ for (const line of lines)
28
+ print(service, line);
29
+ if (build.finishedAt !== null) {
30
+ if (followed.held !== '')
31
+ print(service, followed.held);
32
+ followed.finished = true;
33
+ }
34
+ }
35
+ };
36
+ }
@@ -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 'in-progress':
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 === 'queued' ? `${label} (queued)` : label);
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 dataOrThrow(api.GET('/api/projects/{projectId}/builds/{buildId}/logs', {
69
- params: { path: { projectId: project.id, buildId } },
70
- parseAs: 'text',
71
- }));
75
+ const log = await wholeBuildLog(api, project.id, buildId);
72
76
  return log
73
77
  .split('\n')
74
78
  .filter((line) => line.trim() !== '')
@@ -10,7 +10,7 @@ export const realClock = { now: () => Date.now(), sleep: (ms) => pause(ms) };
10
10
  export function isTransient(error) {
11
11
  return (error instanceof CliError && (error.code === 'unreachable' || error.code === 'unavailable'));
12
12
  }
13
- export async function retryTransient(work, clock = realClock) {
13
+ export async function retryTransient(work, clock = realClock, onWaiting) {
14
14
  let failingSince = null;
15
15
  for (let attempt = 0;; attempt += 1) {
16
16
  const started = clock.now();
@@ -22,6 +22,8 @@ export async function retryTransient(work, clock = realClock) {
22
22
  catch (error) {
23
23
  if (!isTransient(error))
24
24
  throw error;
25
+ if (failingSince === null)
26
+ onWaiting?.();
25
27
  failingSince ??= started;
26
28
  if (clock.now() - failingSince >= GIVE_UP_AFTER_MS)
27
29
  throw error;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grayspar/cli",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "The cloud for small software. Deploy your applications with one command, and share them with exactly the people you choose.",
5
5
  "repository": {
6
6
  "type": "git",