@grayspar/cli 0.0.3 → 0.0.5

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.
@@ -87,17 +87,17 @@ accessCommand
87
87
  accessCommand
88
88
  .command('add')
89
89
  .argument('<targets...>', 'email addresses, or @company.example for everyone at a company')
90
- .description('let named people or a whole company open a project')
90
+ .description('let people or a whole company open a project')
91
91
  .action(commandAction((format, _command, targets) => addAccess(targets, format)));
92
92
  accessCommand
93
93
  .command('remove')
94
94
  .argument('<target>', 'an email address, or @company.example')
95
- .description('take someone or a company off a project')
95
+ .description("remove a person or company from a project's list")
96
96
  .action(commandAction((format, _command, target) => removeAccess(target, format)));
97
97
  accessCommand
98
98
  .command('set')
99
99
  .addOption(new Option('--mode <mode>', 'restricted lets in the people and companies on the list, open lets in anyone with the link')
100
100
  .choices(['restricted', 'open'])
101
101
  .makeOptionMandatory())
102
- .description('choose whether the list decides who can open a project')
102
+ .description('choose whether only the list or anyone with the link can open a project')
103
103
  .action(commandAction((format, command) => setGeneralAccess(command.opts().mode, format)));
@@ -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);
@@ -179,7 +179,7 @@ async function printOutcome(api, project, outcome) {
179
179
  }
180
180
  export const deployCommand = new Command('deploy')
181
181
  .argument('[folder]', 'deploy only the files under this folder of the project', '.')
182
- .description('build a project folder and put it online')
182
+ .description('build a folder and put it online as a project')
183
183
  .option('-y, --yes', 'take the plan as shown without asking')
184
184
  .option('--name <name>', 'create a new project with this name')
185
185
  .option('--no-env', 'deploy without reading any .env file')
@@ -61,11 +61,11 @@ envCommand
61
61
  .command('set')
62
62
  .argument('<pairs...>', 'variables written as KEY=VALUE')
63
63
  .addOption(serviceOption('which service to set them on'))
64
- .description('add or change environment variables')
64
+ .description('set variables on a service')
65
65
  .action(commandAction((format, command, pairs) => setVariables(pairs, serviceNameOf(command), format)));
66
66
  envCommand
67
67
  .command('remove')
68
68
  .argument('<keys...>', 'the names of the variables to remove')
69
69
  .addOption(serviceOption('which service to change'))
70
- .description('remove environment variables')
70
+ .description('remove variables from a service')
71
71
  .action(commandAction((format, command, keys) => removeVariables(keys, serviceNameOf(command), format)));
@@ -21,8 +21,8 @@ async function unlink(format) {
21
21
  printResult(format, { status: 'ok' }, () => receipt(`Unlinked ${found.folder}`));
22
22
  }
23
23
  export const linkCommand = new Command('link')
24
- .description('bind this folder to a project')
24
+ .description('link this folder to a project')
25
25
  .action(commandAction((format) => link(format)));
26
26
  export const unlinkCommand = new Command('unlink')
27
- .description('unbind this folder from its project')
27
+ .description('unlink this folder from its project')
28
28
  .action(commandAction((format) => unlink(format)));
@@ -1,6 +1,5 @@
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. The browser hears nothing until
3
- the CLI has traded the code, so what its page says is true. */
1
+ /* Listens on a port the machine picks for one redirect, then closes. The verifier that makes the browser's code usable
2
+ never leaves this process, and the browser hears nothing until the code is traded, so its page tells the truth. */
4
3
  import { createServer } from 'node:http';
5
4
  function browserPage(mark, markColor, title, detail) {
6
5
  return `<!doctype html><meta charset="utf-8"><meta name="color-scheme" content="light dark">
@@ -14,5 +14,5 @@ async function logout(format) {
14
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
15
  }
16
16
  export const logoutCommand = new Command('logout')
17
- .description('forget the saved sign-in on this machine')
17
+ .description('sign out on this machine')
18
18
  .action(commandAction((format) => logout(format)));
@@ -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) {
@@ -75,15 +98,15 @@ async function printBuildLogs(api, format) {
75
98
  }
76
99
  }
77
100
  export const logsCommand = new Command('logs')
78
- .description('show what a service printed while running, or with --build the last build log')
101
+ .description("show a service's logs, or with --build its 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)));
@@ -16,6 +16,6 @@ async function restart(serviceName, format) {
16
16
  printResult(format, { status: 'restarting', project: project.name, service: service.name }, () => `Restarting ${service.name}.\nTo follow it, run\n${follow}`);
17
17
  }
18
18
  export const restartCommand = new Command('restart')
19
- .description('run a service again in place, keeping its volume')
19
+ .description("start a fresh copy of a service's running version")
20
20
  .addOption(serviceOption('which service to restart'))
21
21
  .action(commandAction((format, command) => restart(serviceNameOf(command), format)));
@@ -4,6 +4,7 @@ import { dataOrThrow, signedInClient } from '../lib/client.js';
4
4
  import { commandAction } from '../lib/command-helpers.js';
5
5
  import { CliError } from '../lib/errors.js';
6
6
  import { printResult } from '../lib/output.js';
7
+ import { askText } from '../lib/prompt.js';
7
8
  import { resolveService } from '../lib/resolve-service.js';
8
9
  import { receipt } from '../lib/ui.js';
9
10
  function receiptsFor(service, change) {
@@ -38,7 +39,21 @@ async function setService(serviceName, change, format) {
38
39
  ];
39
40
  printResult(format, { status: 'ok' }, () => lines.join('\n'));
40
41
  }
41
- export const serviceCommand = new Command('service').description('change where a service answers and how it is built');
42
+ async function removeService(serviceName, skipAsking, format) {
43
+ const api = await signedInClient();
44
+ const { project, service } = await resolveService(api, serviceName, '<name>');
45
+ if (!skipAsking) {
46
+ const typed = await askText(`Type ${service.name} to remove it with its volume and variables`, null, '--yes');
47
+ if (typed !== service.name) {
48
+ throw new CliError('name_mismatch', `That isn't ${service.name}. The service stays.`);
49
+ }
50
+ }
51
+ await dataOrThrow(api.DELETE('/api/projects/{projectId}/services/{serviceId}', {
52
+ params: { path: { projectId: project.id, serviceId: service.id } },
53
+ }));
54
+ printResult(format, { status: 'removed', service: service.name, project: project.name }, () => `${receipt(`Removed service ${service.name} from ${project.name}`)}\nAny future grayspar deploy will redeploy the ${service.name} service.`);
55
+ }
56
+ export const serviceCommand = new Command('service').description('change where a service answers and how it is built, or remove it');
42
57
  serviceCommand
43
58
  .command('set')
44
59
  .argument('[name]', 'the service to change, left out when the project has one')
@@ -61,3 +76,9 @@ serviceCommand
61
76
  };
62
77
  return setService(name, change, format);
63
78
  }));
79
+ serviceCommand
80
+ .command('remove')
81
+ .argument('<name>', 'the service to remove')
82
+ .option('-y, --yes', 'remove it without asking')
83
+ .description('remove a service with its volume and variables')
84
+ .action(commandAction((format, command, name) => removeService(name, command.opts().yes === true, format)));
@@ -11,5 +11,5 @@ async function status(format) {
11
11
  printResult(format, project, () => `${color.heading(project.name)}\n\n${servicesTable(project)}`);
12
12
  }
13
13
  export const statusCommand = new Command('status')
14
- .description('show what a project is doing')
14
+ .description("show each service's address and status")
15
15
  .action(commandAction((format) => status(format)));
@@ -66,22 +66,22 @@ export async function removeVolume(mountPath, serviceName, skipAsking, format) {
66
66
  receipts: [`Removed volume ${mountPath} from ${service.name} and deleted its data`],
67
67
  });
68
68
  }
69
- export const volumeCommand = new Command('volume').description('keep a folder across deploys');
69
+ export const volumeCommand = new Command('volume').description('keep folders across deploys with volumes');
70
70
  volumeCommand
71
71
  .command('list', { isDefault: true })
72
72
  .addOption(serviceOption('which service to read'))
73
- .description('list the folders a service keeps')
73
+ .description('list the volumes of a project')
74
74
  .action(commandAction((format, command) => listVolumes(serviceNameOf(command), format)));
75
75
  volumeCommand
76
76
  .command('add')
77
77
  .argument('<path>', 'the folder inside the service to keep, such as /app/data')
78
78
  .addOption(serviceOption('which service keeps it'))
79
- .description('keep a folder across deploys')
79
+ .description('add a volume to a service')
80
80
  .action(commandAction((format, command, path) => addVolume(path, serviceNameOf(command), format)));
81
81
  volumeCommand
82
82
  .command('remove')
83
83
  .argument('<path>', 'a folder the service keeps')
84
84
  .addOption(serviceOption('which service keeps it'))
85
85
  .option('-y, --yes', 'remove it without asking')
86
- .description('stop keeping a folder, and throw its data away')
86
+ .description('delete a volume and its data')
87
87
  .action(commandAction((format, command, path) => removeVolume(path, serviceNameOf(command), command.opts().yes === true, format)));
@@ -12,5 +12,5 @@ async function whoami(format) {
12
12
  printResult(format, { ...profile, teams }, () => `Signed in as ${profile.email}\n\n${table(['TEAM', 'SLUG'], teams.map((team) => [team.name, team.slug]))}`);
13
13
  }
14
14
  export const whoamiCommand = new Command('whoami')
15
- .description('show who this machine is signed in as')
15
+ .description('show which account this machine is signed in with')
16
16
  .action(commandAction((format) => whoami(format)));
package/dist/index.js CHANGED
@@ -1,58 +1,9 @@
1
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);
2
+ import { program } from './program.js';
3
+ // A reader such as `head` can leave early, which ends the output and nothing more.
4
+ process.stdout.on('error', (error) => {
5
+ if (error.code === 'EPIPE')
6
+ process.exit(0);
7
+ throw error;
37
8
  });
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
9
  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() !== '')
@@ -1,6 +1,5 @@
1
- /* A request that never arrived or failed without Grayspar's own answer is tried again with a growing, jittered wait,
2
- for as long as failures follow one another without a break, then the last failure is let through. The two minutes
3
- count from the start of the first failing attempt, and each later attempt is cut off when they are up. */
1
+ /* A request that never arrived or failed without Grayspar's answer retries with growing, jittered waits while failures
2
+ run unbroken, for two minutes from the first failing attempt, which also end the last; then the failure stands. */
4
3
  import { setTimeout as pause } from 'node:timers/promises';
5
4
  import { CliError } from './errors.js';
6
5
  const FIRST_DELAY_MS = 1000;
@@ -10,7 +9,7 @@ export const realClock = { now: () => Date.now(), sleep: (ms) => pause(ms) };
10
9
  export function isTransient(error) {
11
10
  return (error instanceof CliError && (error.code === 'unreachable' || error.code === 'unavailable'));
12
11
  }
13
- export async function retryTransient(work, clock = realClock) {
12
+ export async function retryTransient(work, clock = realClock, onWaiting) {
14
13
  let failingSince = null;
15
14
  for (let attempt = 0;; attempt += 1) {
16
15
  const started = clock.now();
@@ -22,6 +21,8 @@ export async function retryTransient(work, clock = realClock) {
22
21
  catch (error) {
23
22
  if (!isTransient(error))
24
23
  throw error;
24
+ if (failingSince === null)
25
+ onWaiting?.();
25
26
  failingSince ??= started;
26
27
  if (clock.now() - failingSince >= GIVE_UP_AFTER_MS)
27
28
  throw error;
@@ -5,6 +5,8 @@ const RUNTIME_STATUS = {
5
5
  running: { label: 'Running', tint: color.ok },
6
6
  restarting: { label: 'Restarting', tint: untinted },
7
7
  crashed: { label: 'Crashed', tint: color.bad },
8
+ sleeping: { label: 'Sleeping', tint: untinted },
9
+ stopped: { label: 'Stopped', tint: untinted },
8
10
  unknown: { label: 'Unknown', tint: untinted },
9
11
  failed: { label: 'Failed', tint: color.bad },
10
12
  };
@@ -0,0 +1,56 @@
1
+ /* Every command is a singular noun and one of four verbs, or a single flat verb. `.claude/rules/cli.md` holds the
2
+ grammar, and each command's own file holds its flags; here are only the flags they all share. */
3
+ import { Command } from 'commander';
4
+ import { accessCommand } from './commands/access.js';
5
+ import { deployCommand } from './commands/deploy/deploy.js';
6
+ import { envCommand } from './commands/env.js';
7
+ import { linkCommand, unlinkCommand } from './commands/link.js';
8
+ import { loginCommand } from './commands/login/login.js';
9
+ import { logoutCommand } from './commands/logout.js';
10
+ import { logsCommand } from './commands/logs.js';
11
+ import { openPathCommand } from './commands/open-path.js';
12
+ import { projectCommand } from './commands/project.js';
13
+ import { restartCommand } from './commands/restart.js';
14
+ import { serviceCommand } from './commands/service.js';
15
+ import { statusCommand } from './commands/status.js';
16
+ import { volumeCommand } from './commands/volume.js';
17
+ import { whoamiCommand } from './commands/whoami.js';
18
+ import { setJsonOutput } from './lib/prompt.js';
19
+ import { setNamedProject } from './lib/resolve-project.js';
20
+ import { setNamedTeam } from './lib/resolve-team.js';
21
+ import { routeUsageErrors } from './lib/usage-errors.js';
22
+ import { cliVersion } from './lib/version.js';
23
+ export const program = new Command()
24
+ .name('grayspar')
25
+ .description('deploy your applications and share them with exactly the people you choose')
26
+ .version(cliVersion, '--version', 'show the version')
27
+ .option('--json', 'answer in JSON, for scripts and agents')
28
+ .option('--team <slug>', 'which of your teams to work in')
29
+ .option('--project <name>', 'which project to act on, instead of the linked one')
30
+ .option('--no-color', 'print without colour')
31
+ .hook('preAction', (_program, command) => {
32
+ const { json, team, project } = command.optsWithGlobals();
33
+ setJsonOutput(json === true);
34
+ setNamedTeam(team);
35
+ setNamedProject(project);
36
+ });
37
+ for (const command of [
38
+ loginCommand,
39
+ logoutCommand,
40
+ whoamiCommand,
41
+ deployCommand,
42
+ projectCommand,
43
+ accessCommand,
44
+ openPathCommand,
45
+ envCommand,
46
+ volumeCommand,
47
+ serviceCommand,
48
+ linkCommand,
49
+ unlinkCommand,
50
+ statusCommand,
51
+ logsCommand,
52
+ restartCommand,
53
+ ]) {
54
+ program.addCommand(command);
55
+ }
56
+ routeUsageErrors(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grayspar/cli",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
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",