@grayspar/cli 0.0.1 → 0.0.3

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 (38) hide show
  1. package/README.md +3 -3
  2. package/dist/commands/access.js +12 -12
  3. package/dist/commands/deploy/archive.js +124 -35
  4. package/dist/commands/deploy/await-discovery.js +17 -0
  5. package/dist/commands/deploy/deploy-destination.js +101 -0
  6. package/dist/commands/deploy/deploy-flags.js +10 -4
  7. package/dist/commands/deploy/deploy-target.js +19 -0
  8. package/dist/commands/deploy/deploy.js +119 -276
  9. package/dist/commands/deploy/env-files.js +7 -23
  10. package/dist/commands/deploy/git-state.js +19 -6
  11. package/dist/commands/deploy/submit-deploy.js +127 -0
  12. package/dist/commands/deploy/variables-to-send.js +83 -12
  13. package/dist/commands/env.js +13 -12
  14. package/dist/commands/link.js +6 -5
  15. package/dist/commands/login/login.js +11 -6
  16. package/dist/commands/login/open-browser.js +2 -2
  17. package/dist/commands/login/sign-in-callback.js +28 -8
  18. package/dist/commands/logout.js +5 -17
  19. package/dist/commands/logs.js +23 -18
  20. package/dist/commands/open-path.js +4 -4
  21. package/dist/commands/restart.js +7 -5
  22. package/dist/commands/service.js +16 -25
  23. package/dist/commands/volume.js +33 -19
  24. package/dist/index.js +6 -1
  25. package/dist/lib/client.js +33 -7
  26. package/dist/lib/credentials.js +14 -3
  27. package/dist/lib/follow-deployment.js +79 -51
  28. package/dist/lib/output.js +6 -2
  29. package/dist/lib/project-link.js +2 -5
  30. package/dist/lib/prompt.js +43 -5
  31. package/dist/lib/resolve-project.js +6 -2
  32. package/dist/lib/resolve-team.js +1 -1
  33. package/dist/lib/retry-transient.js +32 -0
  34. package/dist/lib/services-table.js +1 -0
  35. package/dist/lib/usage-errors.js +43 -0
  36. package/dist/lib/version.js +1 -1
  37. package/package.json +6 -1
  38. package/dist/lib/listening.js +0 -24
@@ -0,0 +1,127 @@
1
+ import { dataOrThrow } from '../../lib/client.js';
2
+ import { CliError } from '../../lib/errors.js';
3
+ import { askChoice, askText, askYesNo, isInteractive } from '../../lib/prompt.js';
4
+ import { failure } from '../../lib/ui.js';
5
+ import { servicesToSend } from './deploy-flags.js';
6
+ const PATH_POINTER_PATTERN = /^#\/services\/(\d+)\/pathPrefix$/;
7
+ function askedIn(refusal, sent) {
8
+ if (refusal === undefined)
9
+ return null;
10
+ if (refusal.questions !== undefined)
11
+ return { asked: refusal.questions, detail: refusal.detail };
12
+ const paths = (refusal.errors ?? []).flatMap(({ detail, pointer }) => {
13
+ const index = PATH_POINTER_PATTERN.exec(pointer ?? '')?.[1];
14
+ const service = index === undefined ? undefined : sent[Number(index)]?.name;
15
+ return service === undefined ? [] : [{ service, suggestion: null, problem: detail }];
16
+ });
17
+ return paths.length === 0
18
+ ? null
19
+ : { asked: { address: [], paths, dockerfiles: [] }, detail: refusal.detail };
20
+ }
21
+ async function sendDeploy(api, destination, body) {
22
+ if (destination.kind === 'project') {
23
+ const answer = await api.POST('/api/projects/{projectId}/deployments', {
24
+ params: { path: { projectId: destination.project.id } },
25
+ body,
26
+ });
27
+ const asked = askedIn(answer.error, body.services ?? []);
28
+ if (asked !== null)
29
+ return asked;
30
+ const { deployment } = await dataOrThrow(Promise.resolve(answer));
31
+ return { accepted: { project: destination.project, deployment } };
32
+ }
33
+ const answer = await api.POST('/api/teams/{teamId}/projects', {
34
+ params: { path: { teamId: destination.team.id } },
35
+ body: { ...body, name: destination.name },
36
+ });
37
+ const asked = askedIn(answer.error, body.services ?? []);
38
+ if (asked !== null)
39
+ return asked;
40
+ return { accepted: await dataOrThrow(Promise.resolve(answer)) };
41
+ }
42
+ function dockerfileRefusal({ detail }, { dockerfile, service }) {
43
+ return new CliError('needs_an_answer', `${detail}\nAdd --dockerfile ${service}=${dockerfile} to build it, or --yes to leave it out.`, '--dockerfile');
44
+ }
45
+ function refusalFor({ asked, detail }, suggestionsTaken) {
46
+ const action = asked.address.length > 0
47
+ ? 'Choose it with --path <name>=/.'
48
+ : suggestionsTaken
49
+ ? 'Give the path with --path <name>=<path>, or --no-path <name> if it takes no requests.'
50
+ : 'Give each path with --path <name>=<path>, or --no-path <name> if it takes no requests, or take the suggested paths with --yes.';
51
+ return new CliError('needs_a_path', `${detail}\n${action}`, '--path');
52
+ }
53
+ function suggestionsFor(askedQuestions) {
54
+ const taken = new Map();
55
+ for (const { service, suggestion, problem } of askedQuestions.asked.paths) {
56
+ if (suggestion === null || problem !== null)
57
+ throw refusalFor(askedQuestions, true);
58
+ taken.set(service, suggestion);
59
+ }
60
+ if (askedQuestions.asked.address.length > 0)
61
+ throw refusalFor(askedQuestions, true);
62
+ return taken;
63
+ }
64
+ async function askFirstQuestion(asked, address) {
65
+ if (asked.address.length > 0) {
66
+ const root = await askChoice(`Select the service that opens at ${address}`, `Opens at ${address}`, asked.address.map((name) => ({ value: name, label: name })), '--path');
67
+ return [root, '/'];
68
+ }
69
+ const [question] = asked.paths;
70
+ if (question === undefined)
71
+ throw new Error('API asked a question without naming one');
72
+ if (question.problem !== null)
73
+ process.stdout.write(`${failure(question.problem)}\n`);
74
+ return [
75
+ question.service,
76
+ await askText(`Path for ${question.service}`, question.suggestion, '--path'),
77
+ ];
78
+ }
79
+ export async function submitDeploy(api, destination, options, serviceFlags, foundNames, request) {
80
+ const canAsk = isInteractive() && !options.yes;
81
+ const declinedDockerfiles = [];
82
+ let hasAsked = false;
83
+ for (;;) {
84
+ const sent = await sendDeploy(api, destination, {
85
+ ...request,
86
+ services: servicesToSend(foundNames, serviceFlags),
87
+ ...(declinedDockerfiles.length === 0 ? {} : { declinedDockerfiles }),
88
+ });
89
+ if ('accepted' in sent) {
90
+ if (hasAsked)
91
+ process.stdout.write('\n');
92
+ return sent.accepted;
93
+ }
94
+ const [dockerfile] = sent.asked.dockerfiles;
95
+ if (dockerfile !== undefined) {
96
+ if (options.yes) {
97
+ declinedDockerfiles.push(...sent.asked.dockerfiles.map((question) => question.dockerfile));
98
+ continue;
99
+ }
100
+ if (!canAsk)
101
+ throw dockerfileRefusal(sent, dockerfile);
102
+ const builds = await askYesNo(`Build ${dockerfile.dockerfile} from the project's root as the service ${dockerfile.service}?`, false, '--dockerfile');
103
+ hasAsked = true;
104
+ if (!builds) {
105
+ declinedDockerfiles.push(dockerfile.dockerfile);
106
+ continue;
107
+ }
108
+ serviceFlags.dockerfiles.set(dockerfile.service, dockerfile.dockerfile);
109
+ if (serviceFlags.onlyServices.length > 0)
110
+ serviceFlags.onlyServices.push(dockerfile.service);
111
+ continue;
112
+ }
113
+ if (!options.yes && !canAsk)
114
+ throw refusalFor(sent, false);
115
+ const given = options.yes
116
+ ? suggestionsFor(sent)
117
+ : new Map([await askFirstQuestion(sent.asked, addressOf(destination))]);
118
+ hasAsked ||= !options.yes;
119
+ for (const [name, path] of given)
120
+ serviceFlags.paths.set(name, path);
121
+ }
122
+ }
123
+ function addressOf(destination) {
124
+ return destination.kind === 'project'
125
+ ? new URL(destination.project.url).host
126
+ : "the project's address";
127
+ }
@@ -1,11 +1,48 @@
1
- /* The saved variables are the source of truth, so a deploy adds what the project lacks and asks
2
- before a local value replaces a saved one that differs; the server answers without the values. */
1
+ /* The saved variables are the source of truth: a deploy adds what the project lacks once its names have been shown,
2
+ asks before a local value replaces a saved one, and keeps a value that points at this machine on it. */
3
+ import { createHmac } from 'node:crypto';
3
4
  import { stat } from 'node:fs/promises';
4
5
  import { join } from 'node:path';
5
6
  import { dataOrThrow } from '../../lib/client.js';
6
7
  import { CliError } from '../../lib/errors.js';
7
- import { askChoice, isInteractive } from '../../lib/prompt.js';
8
- import { color } from '../../lib/ui.js';
8
+ import { askChoice, askYesNo, isInteractive } from '../../lib/prompt.js';
9
+ import { color, table, warning } from '../../lib/ui.js';
10
+ const LOCAL_HOST_PATTERN = /(?:^|[^\w.-])(?:localhost|127\.0\.0\.1)(?![\w.-])/;
11
+ export function pointsAtThisMachine(value) {
12
+ return LOCAL_HOST_PATTERN.test(value);
13
+ }
14
+ export function comparisonOf(saved, local) {
15
+ return Object.fromEntries(Object.entries(local).map(([service, values]) => [
16
+ service,
17
+ Object.fromEntries(Object.entries(values).map(([key, value]) => {
18
+ const entry = saved[service]?.find((variable) => variable.name === key);
19
+ if (entry === undefined)
20
+ return [key, { state: 'new', savedAt: null }];
21
+ const hash = createHmac('sha256', Buffer.from(entry.salt, 'base64url'))
22
+ .update(value)
23
+ .digest('hex');
24
+ return [
25
+ key,
26
+ { state: hash === entry.hash ? 'same' : 'different', savedAt: entry.updatedAt },
27
+ ];
28
+ })),
29
+ ]));
30
+ }
31
+ export function newVariablesOf(services, variables, comparison) {
32
+ return services.flatMap((service, index) => {
33
+ const found = variables[index];
34
+ if (found === undefined)
35
+ return [];
36
+ return Object.entries(found.values)
37
+ .filter(([key]) => (comparison[service]?.[key]?.state ?? 'new') === 'new')
38
+ .map(([key, value]) => ({
39
+ service,
40
+ key,
41
+ file: found.sourceFiles[key] ?? '.env',
42
+ leftOut: pointsAtThisMachine(value),
43
+ }));
44
+ });
45
+ }
9
46
  export function sendableVariables(services, variables, comparison, fileWins) {
10
47
  return Object.fromEntries(services.map((service, index) => {
11
48
  const values = variables[index]?.values ?? {};
@@ -35,17 +72,51 @@ async function editedAt(root, file) {
35
72
  return null;
36
73
  return stat(join(root, file)).then((found) => found.mtime, () => null);
37
74
  }
38
- export async function variablesToSend(api, project, root, services, variables, preference) {
75
+ function leftOutWarning({ service, key, file }) {
76
+ return warning(`${key} in ${file} points at localhost, so it was left out. Set it with grayspar env set ${key}=… --service ${service}`);
77
+ }
78
+ async function confirmNewVariables(sendable, fresh, { projectName, format, yes }) {
79
+ const leftOut = fresh.filter((variable) => variable.leftOut);
80
+ const offered = fresh.filter((variable) => !variable.leftOut);
81
+ const dropped = (names) => {
82
+ for (const { service, key } of names)
83
+ delete sendable[service]?.[key];
84
+ };
85
+ dropped(leftOut);
86
+ const warnings = leftOut.map(leftOutWarning);
87
+ if (offered.length > 0 && !yes) {
88
+ const one = offered.length === 1;
89
+ const many = one ? 'variable' : 'variables';
90
+ if (format !== 'text' || !isInteractive()) {
91
+ const them = one ? 'it' : 'them';
92
+ throw new CliError('needs_an_answer', `${offered.length} ${many} in .env files ${one ? 'is' : 'are'} new to ${projectName}, and there is no terminal to ask whether to send ${them}.\nSend ${them} with --yes, or deploy without .env files with --no-env.`, '--yes');
93
+ }
94
+ process.stdout.write(`${color.heading(`Found ${offered.length} new ${many}`)}\n\n` +
95
+ `${table(['SERVICE', 'VARIABLE', 'FILE'], offered.map(({ service, key, file }) => [service, key, file]))}\n` +
96
+ (warnings.length === 0 ? '' : `\n${warnings.join('\n')}\n`) +
97
+ '\n');
98
+ const send = await askYesNo(`Send ${offered.length} ${many} to ${projectName}?`, true, '--yes');
99
+ process.stdout.write('\n');
100
+ if (!send)
101
+ dropped(offered);
102
+ }
103
+ else if (warnings.length > 0 && format === 'text') {
104
+ process.stdout.write(`${warnings.join('\n')}\n\n`);
105
+ }
106
+ return { variables: sendable, leftOut };
107
+ }
108
+ export async function variablesToSend(api, project, root, services, variables, envConflict, asking) {
39
109
  const local = Object.fromEntries(services.map((service, index) => [service, variables[index]?.values ?? {}]));
40
- const comparison = await dataOrThrow(api.POST('/api/projects/{id}/env/compare', {
41
- params: { path: { id: project.id } },
42
- body: { services: local },
43
- }));
110
+ const saved = project === null
111
+ ? {}
112
+ : await dataOrThrow(api.GET('/api/projects/{projectId}/env', { params: { path: { projectId: project.id } } }));
113
+ const comparison = comparisonOf(saved, local);
114
+ const fresh = newVariablesOf(services, variables, comparison);
44
115
  const conflicts = services.flatMap((service) => Object.entries(comparison[service] ?? {})
45
116
  .filter(([, entry]) => entry.state === 'different')
46
117
  .map(([key]) => ({ service, key })));
47
- if (conflicts.length === 0 || preference !== undefined) {
48
- return sendableVariables(services, variables, comparison, () => preference === 'file');
118
+ if (conflicts.length === 0 || envConflict !== undefined) {
119
+ return confirmNewVariables(sendableVariables(services, variables, comparison, () => envConflict === 'file'), fresh, asking);
49
120
  }
50
121
  const names = conflicts.map(({ service, key }) => `${key} on ${service}`).join(', ');
51
122
  if (!isInteractive()) {
@@ -65,5 +136,5 @@ export async function variablesToSend(api, project, root, services, variables, p
65
136
  ], '--env-conflict');
66
137
  fileWinsByConflict.set(`${service} ${key}`, pick === 'file');
67
138
  }
68
- return sendableVariables(services, variables, comparison, ({ service, key }) => fileWinsByConflict.get(`${service} ${key}`) === true);
139
+ return confirmNewVariables(sendableVariables(services, variables, comparison, ({ service, key }) => fileWinsByConflict.get(`${service} ${key}`) === true), fresh, asking);
69
140
  }
@@ -4,19 +4,20 @@ import { Command } from 'commander';
4
4
  import { dataOrThrow, signedInClient } from '../lib/client.js';
5
5
  import { commandAction, serviceNameOf, serviceOption } from '../lib/command-helpers.js';
6
6
  import { CliError } from '../lib/errors.js';
7
- import { appliesOnNextDeploy, followRedeploy } from '../lib/follow-deployment.js';
7
+ import { followRedeploy } from '../lib/follow-deployment.js';
8
8
  import { printResult } from '../lib/output.js';
9
9
  import { resolveService, resolveServices } from '../lib/resolve-service.js';
10
10
  import { tableOrEmptyNote } from '../lib/ui.js';
11
- async function saveVariables(api, project, service, change, format, receipt) {
12
- const saved = await dataOrThrow(api.PUT('/api/projects/{id}/services/{serviceId}/env', {
13
- params: { path: { id: project.id, serviceId: service.id } },
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
14
  body: change,
15
15
  }));
16
- await followRedeploy(api, project, saved.deployment, format, {
17
- receipts: [receipt],
18
- whenNothingIsLive: appliesOnNextDeploy(project),
19
- });
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 });
20
21
  }
21
22
  async function setVariables(pairs, serviceName, format) {
22
23
  const api = await signedInClient();
@@ -29,15 +30,15 @@ async function setVariables(pairs, serviceName, format) {
29
30
  }
30
31
  variables[pair.slice(0, equals)] = pair.slice(equals + 1);
31
32
  }
32
- await saveVariables(api, project, service, { variables }, format, `Set ${Object.keys(variables).join(', ')} on ${service.name}`);
33
+ await saveVariables(api, project, service, { variables }, format, Object.keys(variables).map((key) => `Set variable ${key} on ${service.name}`));
33
34
  }
34
35
  async function listVariables(serviceName, format) {
35
36
  const api = await signedInClient();
36
37
  const { project, services } = await resolveServices(api, serviceName);
37
38
  const listed = await Promise.all(services.map(async (service) => ({
38
39
  service: service.name,
39
- variables: await dataOrThrow(api.GET('/api/projects/{id}/services/{serviceId}/env', {
40
- params: { path: { id: project.id, serviceId: service.id } },
40
+ variables: await dataOrThrow(api.GET('/api/projects/{projectId}/services/{serviceId}/env', {
41
+ params: { path: { projectId: project.id, serviceId: service.id } },
41
42
  })),
42
43
  })));
43
44
  printResult(format, listed, () => {
@@ -48,7 +49,7 @@ async function listVariables(serviceName, format) {
48
49
  async function removeVariables(keys, serviceName, format) {
49
50
  const api = await signedInClient();
50
51
  const { project, service } = await resolveService(api, serviceName);
51
- await saveVariables(api, project, service, { remove: keys }, format, `Removed ${keys.join(', ')} from ${service.name}`);
52
+ await saveVariables(api, project, service, { remove: keys }, format, keys.map((key) => `Removed variable ${key} from ${service.name}`));
52
53
  }
53
54
  export const envCommand = new Command('env').description("set a project's environment variables");
54
55
  envCommand
@@ -3,7 +3,7 @@ import { signedInClient } from '../lib/client.js';
3
3
  import { commandAction } from '../lib/command-helpers.js';
4
4
  import { CliError } from '../lib/errors.js';
5
5
  import { printResult } from '../lib/output.js';
6
- import { removeLink, writeLink } from '../lib/project-link.js';
6
+ import { findLink, removeLink, writeLink } from '../lib/project-link.js';
7
7
  import { resolveProject } from '../lib/resolve-project.js';
8
8
  import { receipt } from '../lib/ui.js';
9
9
  async function link(format) {
@@ -13,11 +13,12 @@ async function link(format) {
13
13
  printResult(format, { status: 'ok', project: project.name }, () => receipt(`Linked this folder to ${project.name}`));
14
14
  }
15
15
  async function unlink(format) {
16
- const existed = await removeLink(process.cwd());
17
- if (!existed) {
18
- throw new CliError('not_linked', 'This folder is not linked to a project.');
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
19
  }
20
- printResult(format, { status: 'ok' }, () => receipt('Unlinked this folder'));
20
+ await removeLink(found.folder);
21
+ printResult(format, { status: 'ok' }, () => receipt(`Unlinked ${found.folder}`));
21
22
  }
22
23
  export const linkCommand = new Command('link')
23
24
  .description('bind this folder to a project')
@@ -1,10 +1,11 @@
1
1
  import { randomBytes } from 'node:crypto';
2
+ import { hostname } from 'node:os';
2
3
  import { Command } from 'commander';
3
- import { DEFAULT_API_URL, publicClient } from '../../lib/client.js';
4
+ import { DEFAULT_API_URL, endSavedSession, publicClient } from '../../lib/client.js';
4
5
  import { commandAction } from '../../lib/command-helpers.js';
5
6
  import { writeCredentials } from '../../lib/credentials.js';
6
7
  import { CliError } from '../../lib/errors.js';
7
- import { printResult, printText, startProgress } from '../../lib/output.js';
8
+ import { printResult, startProgress } from '../../lib/output.js';
8
9
  import { color, receipt } from '../../lib/ui.js';
9
10
  import { challengeFor } from './challenge.js';
10
11
  import { openBrowser } from './open-browser.js';
@@ -14,10 +15,10 @@ async function login(options) {
14
15
  const apiUrl = process.env.GRAYSPAR_API_URL ?? DEFAULT_API_URL;
15
16
  const verifier = randomBytes(32).toString('base64url');
16
17
  const listener = await waitForSignInRedirect(SIGN_IN_WAIT_MS);
17
- const url = new URL('/api/cli/authorize', apiUrl);
18
+ const url = new URL('/cli', apiUrl);
18
19
  url.searchParams.set('port', String(listener.port));
19
20
  url.searchParams.set('challenge', challengeFor(verifier));
20
- printText(options.format, `${color.heading('Grayspar login')}\n${url.toString()}`);
21
+ process.stderr.write(`${options.format === 'text' ? `${color.heading('Grayspar login')}\n` : ''}${url.toString()}\n`);
21
22
  try {
22
23
  if (options.browser)
23
24
  openBrowser(url.toString());
@@ -28,20 +29,24 @@ async function login(options) {
28
29
  throw new CliError('sign_in_timed_out', 'Nothing came back from the browser in time.');
29
30
  }
30
31
  const { data: session } = await publicClient(apiUrl).POST('/api/cli/token', {
31
- body: { code, verifier },
32
+ body: { code, verifier, deviceName: hostname() },
32
33
  });
33
34
  if (session === undefined) {
34
- throw new CliError('sign_in_refused', 'That sign-in did not complete. Try again.');
35
+ throw new CliError('sign_in_refused', "That sign-in didn't complete. Try again.");
35
36
  }
37
+ // The session these credentials replace would otherwise stay valid for months with nothing holding it.
38
+ await endSavedSession();
36
39
  await writeCredentials({
37
40
  apiUrl,
38
41
  token: session.token,
39
42
  email: session.email,
40
43
  });
44
+ await listener.answerBrowser(true);
41
45
  progress.stop();
42
46
  printResult(options.format, { status: 'ok', email: session.email }, () => receipt(`Signed in as ${session.email}`));
43
47
  }
44
48
  catch (error) {
49
+ await listener.answerBrowser(false);
45
50
  progress.stop();
46
51
  throw error;
47
52
  }
@@ -1,5 +1,5 @@
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. */
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
3
  import { execFile } from 'node:child_process';
4
4
  const BROWSER_OPENERS = {
5
5
  darwin: (url) => ['open', [url]],
@@ -1,33 +1,53 @@
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
- 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>`;
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.');
8
15
  export async function waitForSignInRedirect(timeoutMs) {
9
16
  let settle = () => undefined;
10
17
  const waitForCode = new Promise((resolve) => {
11
18
  settle = resolve;
12
19
  });
20
+ let browser = null;
21
+ let redirected = false;
13
22
  const server = createServer((request, response) => {
14
23
  const url = new URL(request.url ?? '/', 'http://127.0.0.1');
15
- if (url.pathname !== '/callback') {
24
+ if (url.pathname !== '/callback' || redirected) {
16
25
  response.writeHead(404).end();
17
26
  return;
18
27
  }
19
- response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(SIGNED_IN_PAGE);
28
+ redirected = true;
29
+ browser = response;
20
30
  settle({ code: url.searchParams.get('code') });
21
31
  });
22
32
  await new Promise((ready) => server.listen(0, '127.0.0.1', ready));
23
33
  const timer = setTimeout(() => settle({ code: null }), timeoutMs);
24
34
  const address = server.address();
25
35
  if (address === null || typeof address === 'string') {
26
- throw new Error('The sign-in listener did not get a port.');
36
+ throw new Error('sign-in listener did not get a port');
27
37
  }
28
38
  return {
29
39
  port: address.port,
30
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
+ }),
31
51
  close: () => {
32
52
  clearTimeout(timer);
33
53
  server.close();
@@ -1,29 +1,17 @@
1
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. */
2
+ forgotten here all the same, and the person is told how to end it from the dashboard. */
3
3
  import { Command } from 'commander';
4
- import { signedInClient } from '../lib/client.js';
4
+ import { endSavedSession } from '../lib/client.js';
5
5
  import { commandAction } from '../lib/command-helpers.js';
6
- import { clearCredentials, readCredentials } from '../lib/credentials.js';
6
+ import { clearCredentials } from '../lib/credentials.js';
7
7
  import { printResult } from '../lib/output.js';
8
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
9
  async function logout(format) {
22
- const ended = await endSessionOnGrayspar();
10
+ const ended = await endSavedSession();
23
11
  await clearCredentials();
24
12
  printResult(format, { status: 'ok', revoked: ended }, () => ended
25
13
  ? 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.')}`);
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.")}`);
27
15
  }
28
16
  export const logoutCommand = new Command('logout')
29
17
  .description('forget the saved sign-in on this machine')
@@ -7,6 +7,7 @@ import { commandAction, serviceOption } from '../lib/command-helpers.js';
7
7
  import { CliError } from '../lib/errors.js';
8
8
  import { resolveProject } from '../lib/resolve-project.js';
9
9
  import { resolveService } from '../lib/resolve-service.js';
10
+ import { retryTransient } from '../lib/retry-transient.js';
10
11
  import { color } from '../lib/ui.js';
11
12
  const FOLLOW_INTERVAL_MS = 2_000;
12
13
  async function logs(options, format) {
@@ -14,27 +15,25 @@ async function logs(options, format) {
14
15
  if (options.build)
15
16
  return printBuildLogs(api, format);
16
17
  const { project, service } = await resolveService(api, options.service);
17
- const readLogs = (after) => dataOrThrow(api.GET('/api/projects/{id}/services/{serviceId}/logs', {
18
+ const readLogs = (after, signal) => dataOrThrow(api.GET('/api/projects/{projectId}/services/{serviceId}/logs', {
18
19
  params: {
19
- path: { id: project.id, serviceId: service.id },
20
+ path: { projectId: project.id, serviceId: service.id },
20
21
  query: after === undefined ? {} : { after },
21
22
  },
23
+ signal,
22
24
  }));
23
25
  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);
26
+ if (!options.follow && format === 'text' && first.lines.length === 0) {
27
+ process.stdout.write(`${service.name} has printed nothing yet.\n`);
31
28
  return;
32
29
  }
33
30
  printLogLines(first.lines, format);
31
+ if (!options.follow)
32
+ return;
34
33
  let after = first.lines.at(-1)?.id;
35
34
  for (;;) {
36
35
  await pause(FOLLOW_INTERVAL_MS);
37
- const next = await readLogs(after);
36
+ const next = await retryTransient((signal) => readLogs(after, signal));
38
37
  printLogLines(next.lines, format);
39
38
  after = next.lines.at(-1)?.id ?? after;
40
39
  }
@@ -48,21 +47,27 @@ function printLogLines(lines, format) {
48
47
  }
49
48
  async function printBuildLogs(api, format) {
50
49
  const project = await resolveProject(api);
51
- const deployments = await dataOrThrow(api.GET('/api/projects/{id}/deployments', { params: { path: { id: project.id } } }));
50
+ const { deployments } = await dataOrThrow(api.GET('/api/projects/{projectId}/deployments', {
51
+ params: { path: { projectId: project.id } },
52
+ }));
52
53
  const latest = deployments[0];
53
54
  if (latest === undefined) {
54
- throw new CliError('never_deployed', `${project.name} has not been deployed yet.`);
55
+ throw new CliError('never_deployed', `${project.name} hasn't been deployed yet.`);
55
56
  }
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 } },
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 } },
61
62
  parseAs: 'text',
62
63
  })),
63
64
  })));
64
65
  if (format === 'json') {
65
- process.stdout.write(`${JSON.stringify({ deployment: latest.id, builds }, null, 2)}\n`);
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
+ }
66
71
  return;
67
72
  }
68
73
  for (const build of builds) {
@@ -14,8 +14,8 @@ async function listOpenPaths(format) {
14
14
  async function addOpenPath(path, format) {
15
15
  const api = await signedInClient();
16
16
  const project = await resolveProject(api);
17
- await dataOrThrow(api.POST('/api/projects/{id}/open-paths', {
18
- params: { path: { id: project.id } },
17
+ await dataOrThrow(api.POST('/api/projects/{projectId}/open-paths', {
18
+ params: { path: { projectId: project.id } },
19
19
  body: { path },
20
20
  }));
21
21
  printResult(format, { status: 'ok', path }, () => `${receipt(`Added open path ${path} to ${project.name}`)}\nAnyone can reach it without signing in.`);
@@ -23,8 +23,8 @@ async function addOpenPath(path, format) {
23
23
  async function removeOpenPath(path, format) {
24
24
  const api = await signedInClient();
25
25
  const project = await resolveProject(api);
26
- await dataOrThrow(api.DELETE('/api/projects/{id}/open-paths', {
27
- params: { path: { id: project.id }, query: { path } },
26
+ await dataOrThrow(api.DELETE('/api/projects/{projectId}/open-paths', {
27
+ params: { path: { projectId: project.id }, query: { path } },
28
28
  }));
29
29
  printResult(format, { status: 'ok', path }, () => receipt(`Removed open path ${path} from ${project.name}`));
30
30
  }
@@ -1,19 +1,21 @@
1
- /* A restart runs the same container again in place, so the service keeps its volumes and its address,
1
+ /* A restart runs the same container again in place, so the service keeps its volume and its address,
2
2
  and nothing is built. The worker does it moments after the API accepts it. */
3
3
  import { Command } from 'commander';
4
4
  import { dataOrThrow, signedInClient } from '../lib/client.js';
5
5
  import { commandAction, serviceNameOf, serviceOption } from '../lib/command-helpers.js';
6
6
  import { printResult } from '../lib/output.js';
7
+ import { commandFor } from '../lib/resolve-project.js';
7
8
  import { resolveService } from '../lib/resolve-service.js';
8
9
  async function restart(serviceName, format) {
9
10
  const api = await signedInClient();
10
11
  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 } },
12
+ await dataOrThrow(api.POST('/api/projects/{projectId}/services/{serviceId}/restart', {
13
+ params: { path: { projectId: project.id, serviceId: service.id } },
13
14
  }));
14
- printResult(format, { status: 'restarting', project: project.name, service: service.name }, () => `Restarting ${service.name}.\nTo follow it, run\ngrayspar status --project ${project.name}`);
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}`);
15
17
  }
16
18
  export const restartCommand = new Command('restart')
17
- .description('run a service again in place, keeping its volumes')
19
+ .description('run a service again in place, keeping its volume')
18
20
  .addOption(serviceOption('which service to restart'))
19
21
  .action(commandAction((format, command) => restart(serviceNameOf(command), format)));