@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.
- package/LICENSE +202 -0
- package/README.md +76 -2
- package/dist/commands/access.js +103 -0
- package/dist/commands/deploy/archive.js +156 -0
- package/dist/commands/deploy/await-discovery.js +17 -0
- package/dist/commands/deploy/deploy-destination.js +101 -0
- package/dist/commands/deploy/deploy-flags.js +60 -0
- package/dist/commands/deploy/deploy-target.js +19 -0
- package/dist/commands/deploy/deploy.js +209 -0
- package/dist/commands/deploy/env-files.js +47 -0
- package/dist/commands/deploy/git-state.js +35 -0
- package/dist/commands/deploy/project-name.js +13 -0
- package/dist/commands/deploy/submit-deploy.js +127 -0
- package/dist/commands/deploy/variables-to-send.js +140 -0
- package/dist/commands/env.js +71 -0
- package/dist/commands/link.js +28 -0
- package/dist/commands/login/challenge.js +5 -0
- package/dist/commands/login/login.js +59 -0
- package/dist/commands/login/open-browser.js +15 -0
- package/dist/commands/login/sign-in-callback.js +37 -0
- package/dist/commands/logout.js +18 -0
- package/dist/commands/logs.js +89 -0
- package/dist/commands/open-path.js +45 -0
- package/dist/commands/project.js +22 -0
- package/dist/commands/restart.js +21 -0
- package/dist/commands/service.js +63 -0
- package/dist/commands/status.js +15 -0
- package/dist/commands/volume.js +87 -0
- package/dist/commands/whoami.js +16 -0
- package/dist/index.js +58 -0
- package/dist/lib/client.js +71 -0
- package/dist/lib/command-helpers.js +25 -0
- package/dist/lib/credentials.js +49 -0
- package/dist/lib/errors.js +12 -0
- package/dist/lib/follow-deployment.js +127 -0
- package/dist/lib/output.js +40 -0
- package/dist/lib/project-link.js +42 -0
- package/dist/lib/prompt.js +103 -0
- package/dist/lib/resolve-project.js +60 -0
- package/dist/lib/resolve-service.js +29 -0
- package/dist/lib/resolve-team.js +31 -0
- package/dist/lib/retry-transient.js +32 -0
- package/dist/lib/services-table.js +29 -0
- package/dist/lib/ui.js +58 -0
- package/dist/lib/usage-errors.js +43 -0
- package/dist/lib/version.js +12 -0
- package/package.json +52 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
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 { setJsonOutput } from './lib/prompt.js';
|
|
20
|
+
import { setNamedProject } from './lib/resolve-project.js';
|
|
21
|
+
import { setNamedTeam } from './lib/resolve-team.js';
|
|
22
|
+
import { routeUsageErrors } from './lib/usage-errors.js';
|
|
23
|
+
import { cliVersion } from './lib/version.js';
|
|
24
|
+
const program = new Command()
|
|
25
|
+
.name('grayspar')
|
|
26
|
+
.description('deploy your applications and share them with exactly the people you choose')
|
|
27
|
+
.version(cliVersion, '--version', 'show the version')
|
|
28
|
+
.option('--json', 'answer in JSON, for scripts and agents')
|
|
29
|
+
.option('--team <slug>', 'which of your teams to work in')
|
|
30
|
+
.option('--project <name>', 'which project to act on, instead of the linked one')
|
|
31
|
+
.option('--no-color', 'print without colour')
|
|
32
|
+
.hook('preAction', (_program, command) => {
|
|
33
|
+
const { json, team, project } = command.optsWithGlobals();
|
|
34
|
+
setJsonOutput(json === true);
|
|
35
|
+
setNamedTeam(team);
|
|
36
|
+
setNamedProject(project);
|
|
37
|
+
});
|
|
38
|
+
for (const command of [
|
|
39
|
+
loginCommand,
|
|
40
|
+
logoutCommand,
|
|
41
|
+
whoamiCommand,
|
|
42
|
+
deployCommand,
|
|
43
|
+
projectCommand,
|
|
44
|
+
accessCommand,
|
|
45
|
+
openPathCommand,
|
|
46
|
+
envCommand,
|
|
47
|
+
volumeCommand,
|
|
48
|
+
serviceCommand,
|
|
49
|
+
linkCommand,
|
|
50
|
+
unlinkCommand,
|
|
51
|
+
statusCommand,
|
|
52
|
+
logsCommand,
|
|
53
|
+
restartCommand,
|
|
54
|
+
]) {
|
|
55
|
+
program.addCommand(command);
|
|
56
|
+
}
|
|
57
|
+
routeUsageErrors(program);
|
|
58
|
+
await program.parseAsync(process.argv);
|
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
const API_TIMEOUT_MS = 2 * 60 * 1000;
|
|
9
|
+
export async function signedInClient() {
|
|
10
|
+
const credentials = await readCredentials();
|
|
11
|
+
if (credentials === null) {
|
|
12
|
+
throw new CliError('not_signed_in', "You aren't signed in. Run grayspar login.");
|
|
13
|
+
}
|
|
14
|
+
const override = process.env.GRAYSPAR_API_URL;
|
|
15
|
+
if (override !== undefined && override !== credentials.apiUrl) {
|
|
16
|
+
throw new CliError('signed_in_elsewhere', `You're signed in to ${credentials.apiUrl}, not ${override}.\nRun grayspar login to sign in there.`);
|
|
17
|
+
}
|
|
18
|
+
return clientAt(credentials.apiUrl, { authorization: `Bearer ${credentials.token}` });
|
|
19
|
+
}
|
|
20
|
+
// It ends the saved session on the Grayspar that issued it, and says whether that Grayspar no longer honours it.
|
|
21
|
+
export async function endSavedSession() {
|
|
22
|
+
const credentials = await readCredentials();
|
|
23
|
+
if (credentials === null)
|
|
24
|
+
return true;
|
|
25
|
+
try {
|
|
26
|
+
const api = clientAt(credentials.apiUrl, { authorization: `Bearer ${credentials.token}` });
|
|
27
|
+
const { response } = await api.DELETE('/api/cli/session');
|
|
28
|
+
return response.ok || response.status === 401;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function publicClient(baseUrl) {
|
|
35
|
+
return clientAt(baseUrl, {});
|
|
36
|
+
}
|
|
37
|
+
function clientAt(baseUrl, headers) {
|
|
38
|
+
const client = createClient({
|
|
39
|
+
baseUrl,
|
|
40
|
+
headers: { ...headers, 'x-grayspar-cli': cliVersion },
|
|
41
|
+
fetch: (request) => fetch(request, {
|
|
42
|
+
signal: AbortSignal.any([request.signal, AbortSignal.timeout(API_TIMEOUT_MS)]),
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
client.use({
|
|
46
|
+
onError: () => new CliError('unreachable', `Can't reach Grayspar at ${baseUrl}.\nCheck your connection, then run the command again.`),
|
|
47
|
+
});
|
|
48
|
+
return client;
|
|
49
|
+
}
|
|
50
|
+
export async function dataOrThrow(pending) {
|
|
51
|
+
const answer = await pending;
|
|
52
|
+
if (!('error' in answer))
|
|
53
|
+
return answer.data;
|
|
54
|
+
const { status } = answer.response;
|
|
55
|
+
if (status === 401) {
|
|
56
|
+
throw new CliError('not_signed_in', 'Your sign-in is no longer valid. Run grayspar login.');
|
|
57
|
+
}
|
|
58
|
+
const { error } = answer;
|
|
59
|
+
const detail = typeof error === 'object' &&
|
|
60
|
+
error !== null &&
|
|
61
|
+
'detail' in error &&
|
|
62
|
+
typeof error.detail === 'string'
|
|
63
|
+
? error.detail
|
|
64
|
+
: null;
|
|
65
|
+
if (status >= 500) {
|
|
66
|
+
throw detail === null
|
|
67
|
+
? new CliError('unavailable', "Grayspar didn't answer. Try again in a moment.")
|
|
68
|
+
: new CliError('server_error', detail);
|
|
69
|
+
}
|
|
70
|
+
throw new CliError('refused', detail ?? "Grayspar didn't accept that request.");
|
|
71
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/* The file holds a session token, so the directory and the file are readable only by their owner and
|
|
2
|
+
the token is never printed, not even by whoami. */
|
|
3
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
export function credentialsPath() {
|
|
7
|
+
const base = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config');
|
|
8
|
+
return join(base, 'grayspar', 'credentials.json');
|
|
9
|
+
}
|
|
10
|
+
export async function readCredentials() {
|
|
11
|
+
try {
|
|
12
|
+
const raw = await readFile(credentialsPath(), 'utf8');
|
|
13
|
+
const parsed = JSON.parse(raw);
|
|
14
|
+
return isCredentials(parsed) ? parsed : null;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function writeCredentials(credentials) {
|
|
21
|
+
const path = credentialsPath();
|
|
22
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
23
|
+
await chmod(dirname(path), 0o700);
|
|
24
|
+
const pending = `${path}.${String(process.pid)}.tmp`;
|
|
25
|
+
await rm(pending, { force: true });
|
|
26
|
+
try {
|
|
27
|
+
await writeFile(pending, `${JSON.stringify(credentials, null, 2)}\n`, {
|
|
28
|
+
mode: 0o600,
|
|
29
|
+
flag: 'wx',
|
|
30
|
+
});
|
|
31
|
+
await rename(pending, path);
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
await rm(pending, { force: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function clearCredentials() {
|
|
38
|
+
await rm(credentialsPath(), { force: true });
|
|
39
|
+
}
|
|
40
|
+
function isCredentials(value) {
|
|
41
|
+
return (typeof value === 'object' &&
|
|
42
|
+
value !== null &&
|
|
43
|
+
'token' in value &&
|
|
44
|
+
typeof value.token === 'string' &&
|
|
45
|
+
'apiUrl' in value &&
|
|
46
|
+
typeof value.apiUrl === 'string' &&
|
|
47
|
+
'email' in value &&
|
|
48
|
+
typeof value.email === 'string');
|
|
49
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/* Every refusal names the flag that would have answered it, so an agent reading the JSON knows what
|
|
2
|
+
to add rather than guessing at the shape of the thing that was missing. */
|
|
3
|
+
export class CliError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
supplyWith;
|
|
6
|
+
constructor(code, message, supplyWith) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'CliError';
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.supplyWith = supplyWith;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { dataOrThrow } from './client.js';
|
|
2
|
+
import { CliError } from './errors.js';
|
|
3
|
+
import { printResult, startProgress } from './output.js';
|
|
4
|
+
import { commandFor } from './resolve-project.js';
|
|
5
|
+
import { isTransient, realClock, retryTransient } from './retry-transient.js';
|
|
6
|
+
import { color, failure, receipt } from './ui.js';
|
|
7
|
+
const POLL_INTERVAL_MS = 2000;
|
|
8
|
+
const FAILURE_TAIL_LINES = 12;
|
|
9
|
+
export function outcomeOf(deployment) {
|
|
10
|
+
if (deployment.finishedAt === null)
|
|
11
|
+
return null;
|
|
12
|
+
switch (deployment.status) {
|
|
13
|
+
case 'succeeded':
|
|
14
|
+
return { status: 'live', deployment: deployment.id };
|
|
15
|
+
case 'failed': {
|
|
16
|
+
const failed = deployment.revisions.find((revision) => revision.status === 'failed');
|
|
17
|
+
if (failed === undefined) {
|
|
18
|
+
throw new Error(`API reports deployment ${deployment.id} failed without a failed revision`);
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
status: 'failed',
|
|
22
|
+
deployment: deployment.id,
|
|
23
|
+
failedService: failed.service,
|
|
24
|
+
failedServiceId: failed.serviceId,
|
|
25
|
+
failedRevision: failed.id,
|
|
26
|
+
failedBuild: failed.build.id,
|
|
27
|
+
buildFailed: failed.build.status === 'failed',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
case 'cancelled':
|
|
31
|
+
return { status: 'cancelled', deployment: deployment.id };
|
|
32
|
+
case 'queued':
|
|
33
|
+
case 'in-progress':
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function awaitDeployment(api, project, deploymentId, { format, label }, clock = realClock) {
|
|
38
|
+
const progress = startProgress(format, label);
|
|
39
|
+
try {
|
|
40
|
+
for (;;) {
|
|
41
|
+
const current = await retryTransient((signal) => dataOrThrow(api.GET('/api/projects/{projectId}/deployments/{deploymentId}', {
|
|
42
|
+
params: { path: { projectId: project.id, deploymentId } },
|
|
43
|
+
signal,
|
|
44
|
+
})), clock).catch(async (error) => {
|
|
45
|
+
if (!isTransient(error) && !(error instanceof CliError && error.code === 'server_error')) {
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
const follow = await commandFor(project, 'grayspar status');
|
|
49
|
+
throw new CliError(error.code, `${error.code === 'unreachable'
|
|
50
|
+
? "Can't reach Grayspar"
|
|
51
|
+
: 'Grayspar stopped answering because of an internal error'}, so this terminal can't follow the deployment.\nIt goes on without this terminal. To follow it, run\n${follow}`);
|
|
52
|
+
});
|
|
53
|
+
const outcome = outcomeOf(current);
|
|
54
|
+
if (outcome !== null) {
|
|
55
|
+
progress.stop();
|
|
56
|
+
return outcome;
|
|
57
|
+
}
|
|
58
|
+
progress.update(current.status === 'queued' ? `${label} (queued)` : label);
|
|
59
|
+
await clock.sleep(POLL_INTERVAL_MS);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
progress.stop();
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
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
|
+
}));
|
|
72
|
+
return log
|
|
73
|
+
.split('\n')
|
|
74
|
+
.filter((line) => line.trim() !== '')
|
|
75
|
+
.slice(-FAILURE_TAIL_LINES)
|
|
76
|
+
.map((line) => color.dim(line));
|
|
77
|
+
}
|
|
78
|
+
export async function printDeployFailure(api, project, outcome) {
|
|
79
|
+
if (outcome.buildFailed) {
|
|
80
|
+
const tail = await buildLogTail(api, project, outcome.failedBuild);
|
|
81
|
+
const command = await commandFor(project, 'grayspar logs --build');
|
|
82
|
+
process.stdout.write(`${failure(`${outcome.failedService} failed to build`)}\n${tail.join('\n')}\n\n` +
|
|
83
|
+
`To read the whole log, run\n${command}\n`);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
const command = await commandFor(project, `grayspar logs --service ${outcome.failedService}`);
|
|
87
|
+
process.stdout.write(`${failure(`${outcome.failedService} failed to start`)}\nTo see why, run\n${command}\n`);
|
|
88
|
+
}
|
|
89
|
+
process.exitCode = 1;
|
|
90
|
+
}
|
|
91
|
+
export async function printReplacedDeploy(project) {
|
|
92
|
+
const command = await commandFor(project, 'grayspar status');
|
|
93
|
+
process.stdout.write(`${failure(`A newer deploy of ${project.name} replaced this one`)}\nTo follow it, run\n${command}\n`);
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
}
|
|
96
|
+
export function nextDeployNote(project) {
|
|
97
|
+
return `It applies the next time ${project.name} deploys.`;
|
|
98
|
+
}
|
|
99
|
+
export async function followRedeploy(api, project, deployment, format, change) {
|
|
100
|
+
const receipts = change.receipts.map(receipt);
|
|
101
|
+
if (deployment === null) {
|
|
102
|
+
printResult(format, { deployment: null }, () => [
|
|
103
|
+
...receipts,
|
|
104
|
+
...(change.whenNothingIsLive === undefined ? [] : [change.whenNothingIsLive]),
|
|
105
|
+
].join('\n'));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const outcome = await awaitDeployment(api, project, deployment.id, {
|
|
109
|
+
format,
|
|
110
|
+
label: `Redeploying ${project.name}…`,
|
|
111
|
+
});
|
|
112
|
+
if (format === 'json') {
|
|
113
|
+
process.stdout.write(`${JSON.stringify({ deployment: outcome }, null, 2)}\n`);
|
|
114
|
+
if (outcome.status === 'failed' || outcome.status === 'cancelled')
|
|
115
|
+
process.exitCode = 1;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (outcome.status === 'failed') {
|
|
119
|
+
await printDeployFailure(api, project, outcome);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (outcome.status === 'cancelled') {
|
|
123
|
+
await printReplacedDeploy(project);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
process.stdout.write(`${[...receipts, receipt(`Redeployed ${project.name}`)].join('\n')}\n`);
|
|
127
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/* Results go to standard output and diagnostics to standard error, so `--json` output stays machine
|
|
2
|
+
readable even while something is being explained to the person watching. */
|
|
3
|
+
import { CliError } from './errors.js';
|
|
4
|
+
import { isInteractive } from './prompt.js';
|
|
5
|
+
import { failure, spinner } from './ui.js';
|
|
6
|
+
const INTERNAL_FAILURE = 'The command stopped because of an internal error. Run it again.';
|
|
7
|
+
export function printText(format, text) {
|
|
8
|
+
if (format === 'text')
|
|
9
|
+
process.stdout.write(`${text}\n`);
|
|
10
|
+
}
|
|
11
|
+
export function printResult(format, value, text) {
|
|
12
|
+
process.stdout.write(format === 'json' ? `${JSON.stringify(value, null, 2)}\n` : `${text()}\n`);
|
|
13
|
+
}
|
|
14
|
+
export function printNote(format, text) {
|
|
15
|
+
if (format === 'text')
|
|
16
|
+
process.stderr.write(`${text}\n`);
|
|
17
|
+
}
|
|
18
|
+
export function startProgress(format, text) {
|
|
19
|
+
return format === 'text' && isInteractive()
|
|
20
|
+
? spinner(text)
|
|
21
|
+
: { update: () => undefined, stop: () => undefined };
|
|
22
|
+
}
|
|
23
|
+
export function exitWithError(format, error) {
|
|
24
|
+
const known = error instanceof CliError ? error : null;
|
|
25
|
+
if (format === 'json') {
|
|
26
|
+
process.stdout.write(`${JSON.stringify({
|
|
27
|
+
status: 'error',
|
|
28
|
+
error: known?.code ?? 'unexpected',
|
|
29
|
+
message: known?.message ?? INTERNAL_FAILURE,
|
|
30
|
+
...(known === null
|
|
31
|
+
? { cause: error instanceof Error ? error.message : String(error) }
|
|
32
|
+
: {}),
|
|
33
|
+
...(known?.supplyWith === undefined ? {} : { supply_with: known.supplyWith }),
|
|
34
|
+
}, null, 2)}\n`);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
process.stderr.write(`${failure(known?.message ?? INTERNAL_FAILURE)}\n`);
|
|
38
|
+
}
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/* The link binds a folder to a project by id, so renaming the folder never retargets a deploy. It
|
|
2
|
+
lives in .grayspar and ignores itself, so it stays out of the user's commits. */
|
|
3
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
const LINK_FOLDER = '.grayspar';
|
|
6
|
+
const LINK_FILE = 'project.json';
|
|
7
|
+
export async function findLink(startFolder) {
|
|
8
|
+
let folder = resolve(startFolder);
|
|
9
|
+
for (;;) {
|
|
10
|
+
const found = await readLinkAt(join(folder, LINK_FOLDER, LINK_FILE));
|
|
11
|
+
if (found !== null)
|
|
12
|
+
return { ...found, folder };
|
|
13
|
+
const parent = dirname(folder);
|
|
14
|
+
if (parent === folder)
|
|
15
|
+
return null;
|
|
16
|
+
folder = parent;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function writeLink(folder, link) {
|
|
20
|
+
const linkFolder = join(folder, LINK_FOLDER);
|
|
21
|
+
await mkdir(linkFolder, { recursive: true });
|
|
22
|
+
await writeFile(join(linkFolder, LINK_FILE), `${JSON.stringify(link, null, 2)}\n`);
|
|
23
|
+
await writeFile(join(linkFolder, '.gitignore'), '*\n');
|
|
24
|
+
}
|
|
25
|
+
export async function removeLink(folder) {
|
|
26
|
+
await rm(join(folder, LINK_FOLDER), { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
async function readLinkAt(path) {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
31
|
+
return isLink(parsed) ? parsed : null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function isLink(value) {
|
|
38
|
+
return (typeof value === 'object' &&
|
|
39
|
+
value !== null &&
|
|
40
|
+
'projectId' in value &&
|
|
41
|
+
typeof value.projectId === 'string');
|
|
42
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/* Without a terminal there is nobody to answer, so a question becomes an error naming the flag that would have carried
|
|
2
|
+
the answer. A typed question arrives with its suggestion on the line as the answer, and settles into `Label: answer`. */
|
|
3
|
+
import { createPrompt, isEnterKey, useEffect, useKeypress, useState } from '@inquirer/core';
|
|
4
|
+
import select from '@inquirer/select';
|
|
5
|
+
import { CliError } from './errors.js';
|
|
6
|
+
import { color } from './ui.js';
|
|
7
|
+
let jsonOutput = false;
|
|
8
|
+
export function setJsonOutput(isJson) {
|
|
9
|
+
jsonOutput = isJson;
|
|
10
|
+
}
|
|
11
|
+
export function isInteractive() {
|
|
12
|
+
return !jsonOutput && Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
13
|
+
}
|
|
14
|
+
function refuseWithoutTerminal(message, flag) {
|
|
15
|
+
throw new CliError('needs_an_answer', `There is no terminal to answer "${message}".\nGive the answer with ${flag}.`, flag);
|
|
16
|
+
}
|
|
17
|
+
export const CTRL_C_EXIT_CODE = 130;
|
|
18
|
+
export async function exitOnCtrlC(work) {
|
|
19
|
+
try {
|
|
20
|
+
return await work();
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error instanceof Error && error.name === 'ExitPromptError') {
|
|
24
|
+
process.exit(CTRL_C_EXIT_CODE);
|
|
25
|
+
}
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function askChoice(message, label, options, flag) {
|
|
30
|
+
if (!isInteractive())
|
|
31
|
+
refuseWithoutTerminal(message, flag);
|
|
32
|
+
const choices = options.map((option) => ({ name: option.label, value: option.value }));
|
|
33
|
+
const theme = {
|
|
34
|
+
prefix: '',
|
|
35
|
+
icon: { cursor: '>' },
|
|
36
|
+
style: {
|
|
37
|
+
message: (text, status) => status === 'done' ? `${label}:` : color.heading(`${text}:`),
|
|
38
|
+
answer: (text) => text,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
return exitOnCtrlC(() => select({ message, choices, theme }));
|
|
42
|
+
}
|
|
43
|
+
const textPrompt = createPrompt((config, done) => {
|
|
44
|
+
const [answered, setAnswered] = useState(false);
|
|
45
|
+
const [value, setValue] = useState(config.suggestion ?? '');
|
|
46
|
+
useEffect((rl) => {
|
|
47
|
+
if (config.suggestion !== null)
|
|
48
|
+
rl.write(config.suggestion);
|
|
49
|
+
}, [config.suggestion]);
|
|
50
|
+
useKeypress((key, rl) => {
|
|
51
|
+
if (answered)
|
|
52
|
+
return;
|
|
53
|
+
if (!isEnterKey(key)) {
|
|
54
|
+
setValue(rl.line);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const answer = value.trim();
|
|
58
|
+
setValue(answer);
|
|
59
|
+
if (answer === '')
|
|
60
|
+
return;
|
|
61
|
+
setAnswered(true);
|
|
62
|
+
done(answer);
|
|
63
|
+
});
|
|
64
|
+
return answered ? `${config.label}: ${value}` : `${color.heading(`${config.label}:`)} ${value}`;
|
|
65
|
+
});
|
|
66
|
+
const yesNoPrompt = createPrompt((config, done) => {
|
|
67
|
+
const [answer, setAnswer] = useState(null);
|
|
68
|
+
const [value, setValue] = useState('');
|
|
69
|
+
useKeypress((key, rl) => {
|
|
70
|
+
if (answer !== null)
|
|
71
|
+
return;
|
|
72
|
+
if (!isEnterKey(key)) {
|
|
73
|
+
setValue(rl.line);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const typed = value.trim().toLowerCase();
|
|
77
|
+
const given = typed === ''
|
|
78
|
+
? config.answerByDefault
|
|
79
|
+
: ['y', 'yes'].includes(typed)
|
|
80
|
+
? true
|
|
81
|
+
: ['n', 'no'].includes(typed)
|
|
82
|
+
? false
|
|
83
|
+
: null;
|
|
84
|
+
setValue('');
|
|
85
|
+
if (given === null)
|
|
86
|
+
return;
|
|
87
|
+
setAnswer(given);
|
|
88
|
+
done(given);
|
|
89
|
+
});
|
|
90
|
+
if (answer !== null)
|
|
91
|
+
return `${config.message} ${answer ? 'Yes' : 'No'}`;
|
|
92
|
+
return `${color.heading(config.message)} ${config.answerByDefault ? '(Y/n)' : '(y/N)'} ${value}`;
|
|
93
|
+
});
|
|
94
|
+
export async function askYesNo(message, answerByDefault, flag) {
|
|
95
|
+
if (!isInteractive())
|
|
96
|
+
refuseWithoutTerminal(message, flag);
|
|
97
|
+
return exitOnCtrlC(() => yesNoPrompt({ message, answerByDefault }));
|
|
98
|
+
}
|
|
99
|
+
export async function askText(label, suggestion, flag) {
|
|
100
|
+
if (!isInteractive())
|
|
101
|
+
refuseWithoutTerminal(label, flag);
|
|
102
|
+
return exitOnCtrlC(() => textPrompt({ label, suggestion }));
|
|
103
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { dataOrThrow } from './client.js';
|
|
2
|
+
import { CliError } from './errors.js';
|
|
3
|
+
import { findLink } from './project-link.js';
|
|
4
|
+
import { askChoice } from './prompt.js';
|
|
5
|
+
import { resolveTeam, teamIsNamed } from './resolve-team.js';
|
|
6
|
+
let namedProject;
|
|
7
|
+
export function setNamedProject(slug) {
|
|
8
|
+
namedProject = slug;
|
|
9
|
+
}
|
|
10
|
+
export function projectIsNamed() {
|
|
11
|
+
return namedProject !== undefined;
|
|
12
|
+
}
|
|
13
|
+
export function teamProjects(api, teamId) {
|
|
14
|
+
return dataOrThrow(api.GET('/api/teams/{teamId}/projects', { params: { path: { teamId } } }));
|
|
15
|
+
}
|
|
16
|
+
export async function commandFor(project, command) {
|
|
17
|
+
const link = await findLink(process.cwd());
|
|
18
|
+
return link?.projectId === project.id ? command : `${command} --project ${project.name}`;
|
|
19
|
+
}
|
|
20
|
+
export async function resolveProject(api, { followLink = true } = {}) {
|
|
21
|
+
if (namedProject !== undefined)
|
|
22
|
+
return projectNamed(api, namedProject);
|
|
23
|
+
const link = followLink ? await findLink(process.cwd()) : null;
|
|
24
|
+
if (link !== null) {
|
|
25
|
+
const answer = await api.GET('/api/projects/{projectId}', {
|
|
26
|
+
params: { path: { projectId: link.projectId } },
|
|
27
|
+
});
|
|
28
|
+
if (answer.response.status === 404) {
|
|
29
|
+
throw new CliError('link_outdated', 'The project this folder is linked to no longer exists. Run grayspar link.', '--project');
|
|
30
|
+
}
|
|
31
|
+
return dataOrThrow(Promise.resolve(answer));
|
|
32
|
+
}
|
|
33
|
+
return selectProject(api, await resolveTeam(api));
|
|
34
|
+
}
|
|
35
|
+
async function selectProject(api, team) {
|
|
36
|
+
const projects = await teamProjects(api, team.id);
|
|
37
|
+
const [first] = projects;
|
|
38
|
+
if (first === undefined) {
|
|
39
|
+
throw new CliError('no_projects', `${team.name} has no projects yet. Deploy one first.`);
|
|
40
|
+
}
|
|
41
|
+
const id = await askChoice('Select a project', 'Project', projects.map((project) => ({ value: project.id, label: project.name })), '--project');
|
|
42
|
+
return projects.find((project) => project.id === id) ?? first;
|
|
43
|
+
}
|
|
44
|
+
async function projectNamed(api, given) {
|
|
45
|
+
const teams = teamIsNamed() ? [await resolveTeam(api)] : await dataOrThrow(api.GET('/api/teams'));
|
|
46
|
+
const matches = [];
|
|
47
|
+
for (const team of teams) {
|
|
48
|
+
const project = (await teamProjects(api, team.id)).find((candidate) => candidate.name === given);
|
|
49
|
+
if (project !== undefined)
|
|
50
|
+
matches.push({ team, project });
|
|
51
|
+
}
|
|
52
|
+
const [only] = matches;
|
|
53
|
+
if (only === undefined) {
|
|
54
|
+
throw new CliError('no_such_project', `You have no project called ${given}.\nRun grayspar project to see your projects.`, '--project');
|
|
55
|
+
}
|
|
56
|
+
if (matches.length === 1)
|
|
57
|
+
return only.project;
|
|
58
|
+
const team = await resolveTeam(api, { among: matches.map((match) => match.team) });
|
|
59
|
+
return matches.find((match) => match.team.id === team.id)?.project ?? only.project;
|
|
60
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { CliError } from './errors.js';
|
|
2
|
+
import { askChoice } from './prompt.js';
|
|
3
|
+
import { resolveProject } from './resolve-project.js';
|
|
4
|
+
export async function resolveService(api, serviceName, answeredBy = '--service') {
|
|
5
|
+
const project = await resolveProject(api);
|
|
6
|
+
const [first] = project.services;
|
|
7
|
+
if (first === undefined) {
|
|
8
|
+
throw new CliError('never_deployed', `${project.name} has no services yet. Deploy it first.`);
|
|
9
|
+
}
|
|
10
|
+
if (serviceName !== undefined) {
|
|
11
|
+
const service = project.services.find((candidate) => candidate.name === serviceName);
|
|
12
|
+
if (service === undefined) {
|
|
13
|
+
throw new CliError('no_such_service', `${project.name} has no service called ${serviceName}.\nRun grayspar status to see its services.`, answeredBy);
|
|
14
|
+
}
|
|
15
|
+
return { project, service };
|
|
16
|
+
}
|
|
17
|
+
if (project.services.length === 1)
|
|
18
|
+
return { project, service: first };
|
|
19
|
+
const id = await askChoice('Select a service', 'Service', project.services.map((service) => ({ value: service.id, label: service.name })), answeredBy);
|
|
20
|
+
return { project, service: project.services.find((service) => service.id === id) ?? first };
|
|
21
|
+
}
|
|
22
|
+
export async function resolveServices(api, serviceName) {
|
|
23
|
+
if (serviceName === undefined) {
|
|
24
|
+
const project = await resolveProject(api);
|
|
25
|
+
return { project, services: project.services };
|
|
26
|
+
}
|
|
27
|
+
const { project, service } = await resolveService(api, serviceName);
|
|
28
|
+
return { project, services: [service] };
|
|
29
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { dataOrThrow } from './client.js';
|
|
2
|
+
import { CliError } from './errors.js';
|
|
3
|
+
import { askChoice } from './prompt.js';
|
|
4
|
+
let namedTeam;
|
|
5
|
+
export function setNamedTeam(slug) {
|
|
6
|
+
namedTeam = slug;
|
|
7
|
+
}
|
|
8
|
+
export function teamIsNamed() {
|
|
9
|
+
return namedTeam !== undefined;
|
|
10
|
+
}
|
|
11
|
+
export async function resolveTeam(api, options = {}) {
|
|
12
|
+
const all = options.among ?? (await dataOrThrow(api.GET('/api/teams')));
|
|
13
|
+
const teams = options.deploying === true ? all.filter((team) => team.can.deploy) : all;
|
|
14
|
+
if (namedTeam !== undefined) {
|
|
15
|
+
const team = all.find((candidate) => candidate.slug === namedTeam);
|
|
16
|
+
if (team === undefined) {
|
|
17
|
+
throw new CliError('no_such_team', `You aren't in a team called ${namedTeam}.`, '--team');
|
|
18
|
+
}
|
|
19
|
+
return team;
|
|
20
|
+
}
|
|
21
|
+
const [first] = teams;
|
|
22
|
+
if (first === undefined) {
|
|
23
|
+
throw all.length === 0
|
|
24
|
+
? new CliError('no_team', 'This account belongs to no team.')
|
|
25
|
+
: new CliError('view_only', 'You can only view the projects in your teams, so none can take a deploy.');
|
|
26
|
+
}
|
|
27
|
+
if (teams.length === 1 || options.takeDefault === true)
|
|
28
|
+
return first;
|
|
29
|
+
const id = await askChoice('Select a team', 'Team', teams.map((team) => ({ value: team.id, label: team.name })), '--team');
|
|
30
|
+
return teams.find((team) => team.id === id) ?? first;
|
|
31
|
+
}
|