@grayspar/cli 0.0.0-stage → 0.0.1

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 (42) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +76 -2
  3. package/dist/commands/access.js +103 -0
  4. package/dist/commands/deploy/archive.js +67 -0
  5. package/dist/commands/deploy/deploy-flags.js +54 -0
  6. package/dist/commands/deploy/deploy.js +366 -0
  7. package/dist/commands/deploy/env-files.js +63 -0
  8. package/dist/commands/deploy/git-state.js +22 -0
  9. package/dist/commands/deploy/project-name.js +13 -0
  10. package/dist/commands/deploy/variables-to-send.js +69 -0
  11. package/dist/commands/env.js +70 -0
  12. package/dist/commands/link.js +27 -0
  13. package/dist/commands/login/challenge.js +5 -0
  14. package/dist/commands/login/login.js +56 -0
  15. package/dist/commands/login/open-browser.js +15 -0
  16. package/dist/commands/login/sign-in-callback.js +36 -0
  17. package/dist/commands/logout.js +30 -0
  18. package/dist/commands/logs.js +84 -0
  19. package/dist/commands/open-path.js +45 -0
  20. package/dist/commands/project.js +22 -0
  21. package/dist/commands/restart.js +19 -0
  22. package/dist/commands/service.js +72 -0
  23. package/dist/commands/status.js +15 -0
  24. package/dist/commands/volume.js +73 -0
  25. package/dist/commands/whoami.js +16 -0
  26. package/dist/index.js +53 -0
  27. package/dist/lib/client.js +45 -0
  28. package/dist/lib/command-helpers.js +25 -0
  29. package/dist/lib/credentials.js +38 -0
  30. package/dist/lib/errors.js +12 -0
  31. package/dist/lib/follow-deployment.js +99 -0
  32. package/dist/lib/listening.js +24 -0
  33. package/dist/lib/output.js +36 -0
  34. package/dist/lib/project-link.js +45 -0
  35. package/dist/lib/prompt.js +65 -0
  36. package/dist/lib/resolve-project.js +56 -0
  37. package/dist/lib/resolve-service.js +29 -0
  38. package/dist/lib/resolve-team.js +31 -0
  39. package/dist/lib/services-table.js +28 -0
  40. package/dist/lib/ui.js +58 -0
  41. package/dist/lib/version.js +12 -0
  42. package/package.json +47 -3
@@ -0,0 +1,38 @@
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, 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 writeFile(path, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
24
+ await chmod(path, 0o600);
25
+ }
26
+ export async function clearCredentials() {
27
+ await rm(credentialsPath(), { force: true });
28
+ }
29
+ function isCredentials(value) {
30
+ return (typeof value === 'object' &&
31
+ value !== null &&
32
+ 'token' in value &&
33
+ typeof value.token === 'string' &&
34
+ 'apiUrl' in value &&
35
+ typeof value.apiUrl === 'string' &&
36
+ 'email' in value &&
37
+ typeof value.email === 'string');
38
+ }
@@ -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,99 @@
1
+ import { dataOrThrow } from './client.js';
2
+ import { portLines } from './listening.js';
3
+ import { printResult, startProgress } from './output.js';
4
+ import { color, failure, receipt } from './ui.js';
5
+ const POLL_INTERVAL_MS = 2000;
6
+ const POLL_CEILING_MS = 12 * 60 * 1000;
7
+ export async function awaitDeployment(api, project, deploymentId, { format, label }) {
8
+ const progress = startProgress(format, label);
9
+ const deadline = Date.now() + POLL_CEILING_MS;
10
+ try {
11
+ for (;;) {
12
+ const current = await dataOrThrow(api.GET('/api/projects/{id}/deployments/{deployId}', {
13
+ params: { path: { id: project.id, deployId: deploymentId } },
14
+ }));
15
+ if (current.status === 'failed' ||
16
+ current.status === 'failed-to-start' ||
17
+ current.status === 'crashed') {
18
+ const failed = current.builds.find((build) => build.status === 'failed' ||
19
+ build.status === 'failed-to-start' ||
20
+ build.status === 'crashed') ?? current.builds[0];
21
+ progress.stop();
22
+ return {
23
+ status: 'failed',
24
+ deployment: deploymentId,
25
+ failedBuild: failed?.id ?? '',
26
+ failedService: failed?.service ?? 'A service',
27
+ built: failed !== undefined && failed.status !== 'failed',
28
+ };
29
+ }
30
+ if (current.status === 'live') {
31
+ progress.stop();
32
+ const listening = current.builds.flatMap(({ service, listening: found }) => found === null ? [] : [{ service, ...found }]);
33
+ return { status: 'live', deployment: deploymentId, listening };
34
+ }
35
+ progress.update(current.status === 'queued' ? `${label} (queued)` : label);
36
+ if (Date.now() > deadline) {
37
+ progress.stop();
38
+ return { status: 'pending', deployment: deploymentId };
39
+ }
40
+ await new Promise((settle) => setTimeout(settle, POLL_INTERVAL_MS));
41
+ }
42
+ }
43
+ catch (error) {
44
+ progress.stop();
45
+ throw error;
46
+ }
47
+ }
48
+ export async function reportFailure(api, project, outcome, hint) {
49
+ const log = await dataOrThrow(api.GET('/api/projects/{id}/deployments/{deployId}/builds/{buildId}/logs', {
50
+ params: {
51
+ path: { id: project.id, deployId: outcome.deployment, buildId: outcome.failedBuild },
52
+ },
53
+ parseAs: 'text',
54
+ }));
55
+ const tail = log
56
+ .split('\n')
57
+ .filter((line) => line.trim() !== '')
58
+ .slice(-12)
59
+ .map((line) => color.dim(line));
60
+ const failureStage = outcome.built ? 'failed to start' : 'failed to build';
61
+ process.stdout.write(`${failure(`${outcome.failedService} ${failureStage}`)}\n${tail.join('\n')}\n\n` +
62
+ (hint === undefined ? '' : `${hint.join('\n')}\n\n`) +
63
+ `To read the whole log, run\ngrayspar logs --build --project ${project.name}\n`);
64
+ process.exitCode = 1;
65
+ }
66
+ export function appliesOnNextDeploy(project) {
67
+ return `It applies the next time ${project.name} deploys.`;
68
+ }
69
+ export async function followRedeploy(api, project, deployment, format, change) {
70
+ const receipts = change.receipts.map(receipt);
71
+ if (deployment === null) {
72
+ printResult(format, { status: 'ok', deployment: null }, () => [
73
+ ...receipts,
74
+ ...(change.whenNothingIsLive === undefined ? [] : [change.whenNothingIsLive]),
75
+ ].join('\n'));
76
+ return;
77
+ }
78
+ const outcome = await awaitDeployment(api, project, deployment.id, {
79
+ format,
80
+ label: `Redeploying ${project.name}…`,
81
+ });
82
+ if (format === 'json') {
83
+ process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
84
+ if (outcome.status === 'failed')
85
+ process.exitCode = 1;
86
+ return;
87
+ }
88
+ if (outcome.status === 'failed') {
89
+ await reportFailure(api, project, outcome);
90
+ return;
91
+ }
92
+ if (outcome.status === 'pending') {
93
+ process.stdout.write(`Still redeploying. To follow it, run\ngrayspar status --project ${project.name}\n`);
94
+ return;
95
+ }
96
+ const ports = portLines(outcome.listening);
97
+ process.stdout.write(`${[...receipts, receipt(`Redeployed ${project.name}`), ...ports.receipts].join('\n')}\n` +
98
+ (ports.warnings.length === 0 ? '' : `\n${ports.warnings.join('\n')}\n`));
99
+ }
@@ -0,0 +1,24 @@
1
+ import { receipt, warning } from './ui.js';
2
+ function advice(found) {
3
+ switch (found.kind) {
4
+ case 'several':
5
+ return `listens on ports ${found.ports.join(' and ')}. Choose one with grayspar service set ${found.service} --port …`;
6
+ case 'localhost':
7
+ return 'listens only on localhost, where no request reaches it. Listen on 0.0.0.0';
8
+ case 'none':
9
+ return 'has not opened a port, so no request reaches it. Listen on $PORT';
10
+ }
11
+ }
12
+ export function portLines(listening) {
13
+ const receipts = [];
14
+ const warnings = [];
15
+ for (const found of listening) {
16
+ if (found.kind === 'adopted') {
17
+ receipts.push(receipt(`Set port of ${found.service} to ${found.port}, the port it listens on`));
18
+ }
19
+ else {
20
+ warnings.push(warning(`${found.service} ${advice(found)}`));
21
+ }
22
+ }
23
+ return { receipts, warnings };
24
+ }
@@ -0,0 +1,36 @@
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
+ export function printText(format, text) {
7
+ if (format === 'text')
8
+ process.stdout.write(`${text}\n`);
9
+ }
10
+ export function printResult(format, value, text) {
11
+ process.stdout.write(format === 'json' ? `${JSON.stringify(value, null, 2)}\n` : `${text()}\n`);
12
+ }
13
+ export function printNote(format, text) {
14
+ if (format === 'text')
15
+ process.stderr.write(`${text}\n`);
16
+ }
17
+ export function startProgress(format, text) {
18
+ return format === 'text' && isInteractive()
19
+ ? spinner(text)
20
+ : { update: () => undefined, stop: () => undefined };
21
+ }
22
+ export function exitWithError(format, error) {
23
+ const known = error instanceof CliError ? error : null;
24
+ if (format === 'json') {
25
+ process.stdout.write(`${JSON.stringify({
26
+ status: 'error',
27
+ error: known?.code ?? 'unexpected',
28
+ message: error instanceof Error ? error.message : String(error),
29
+ ...(known?.supplyWith === undefined ? {} : { supply_with: known.supplyWith }),
30
+ }, null, 2)}\n`);
31
+ }
32
+ else {
33
+ process.stderr.write(`${failure(error instanceof Error ? error.message : String(error))}\n`);
34
+ }
35
+ process.exit(1);
36
+ }
@@ -0,0 +1,45 @@
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;
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
+ const linkFolder = join(folder, LINK_FOLDER);
27
+ const existed = (await readLinkAt(join(linkFolder, LINK_FILE))) !== null;
28
+ await rm(linkFolder, { recursive: true, force: true });
29
+ return existed;
30
+ }
31
+ async function readLinkAt(path) {
32
+ try {
33
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
34
+ return isLink(parsed) ? parsed : null;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ function isLink(value) {
41
+ return (typeof value === 'object' &&
42
+ value !== null &&
43
+ 'projectId' in value &&
44
+ typeof value.projectId === 'string');
45
+ }
@@ -0,0 +1,65 @@
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
+ export function isInteractive() {
8
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
9
+ }
10
+ function refuseWithoutTerminal(message, flag) {
11
+ throw new CliError('needs_an_answer', `There is no terminal to answer "${message}".\nGive the answer with ${flag}.`, flag);
12
+ }
13
+ async function cancellable(work, flag) {
14
+ try {
15
+ return await work();
16
+ }
17
+ catch (error) {
18
+ if (error instanceof Error && error.name === 'ExitPromptError') {
19
+ throw new CliError('cancelled', 'Cancelled.', flag);
20
+ }
21
+ throw error;
22
+ }
23
+ }
24
+ export async function askChoice(message, label, options, flag) {
25
+ if (!isInteractive())
26
+ refuseWithoutTerminal(message, flag);
27
+ const choices = options.map((option) => ({ name: option.label, value: option.value }));
28
+ const theme = {
29
+ prefix: '',
30
+ icon: { cursor: '>' },
31
+ style: {
32
+ message: (text, status) => status === 'done' ? `${label}:` : color.heading(`${text}:`),
33
+ answer: (text) => text,
34
+ },
35
+ };
36
+ return cancellable(() => select({ message, choices, theme }), flag);
37
+ }
38
+ const textPrompt = createPrompt((config, done) => {
39
+ const [answered, setAnswered] = useState(false);
40
+ const [value, setValue] = useState(config.suggestion ?? '');
41
+ useEffect((rl) => {
42
+ if (config.suggestion !== null)
43
+ rl.write(config.suggestion);
44
+ }, [config.suggestion]);
45
+ useKeypress((key, rl) => {
46
+ if (answered)
47
+ return;
48
+ if (!isEnterKey(key)) {
49
+ setValue(rl.line);
50
+ return;
51
+ }
52
+ const answer = value.trim();
53
+ setValue(answer);
54
+ if (answer === '')
55
+ return;
56
+ setAnswered(true);
57
+ done(answer);
58
+ });
59
+ return answered ? `${config.label}: ${value}` : `${color.heading(`${config.label}:`)} ${value}`;
60
+ });
61
+ export async function askText(label, suggestion, flag) {
62
+ if (!isInteractive())
63
+ refuseWithoutTerminal(label, flag);
64
+ return cancellable(() => textPrompt({ label, suggestion }), flag);
65
+ }
@@ -0,0 +1,56 @@
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 resolveProject(api, { followLink = true } = {}) {
17
+ if (namedProject !== undefined)
18
+ return projectNamed(api, namedProject);
19
+ const link = followLink ? await findLink(process.cwd()) : null;
20
+ if (link !== null) {
21
+ const answer = await api.GET('/api/projects/{id}', {
22
+ params: { path: { id: link.projectId } },
23
+ });
24
+ if (answer.response.status === 404) {
25
+ throw new CliError('link_outdated', 'The project this folder is linked to no longer exists. Run grayspar link.', '--project');
26
+ }
27
+ return dataOrThrow(Promise.resolve(answer));
28
+ }
29
+ return selectProject(api, await resolveTeam(api));
30
+ }
31
+ async function selectProject(api, team) {
32
+ const projects = await teamProjects(api, team.id);
33
+ const [first] = projects;
34
+ if (first === undefined) {
35
+ throw new CliError('no_projects', `${team.name} has no projects yet. Deploy one first.`);
36
+ }
37
+ const id = await askChoice('Select a project', 'Project', projects.map((project) => ({ value: project.id, label: project.name })), '--project');
38
+ return projects.find((project) => project.id === id) ?? first;
39
+ }
40
+ async function projectNamed(api, given) {
41
+ const teams = teamIsNamed() ? [await resolveTeam(api)] : await dataOrThrow(api.GET('/api/teams'));
42
+ const matches = [];
43
+ for (const team of teams) {
44
+ const project = (await teamProjects(api, team.id)).find((candidate) => candidate.name === given);
45
+ if (project !== undefined)
46
+ matches.push({ team, project });
47
+ }
48
+ const [only] = matches;
49
+ if (only === undefined) {
50
+ throw new CliError('no_such_project', `You have no project called ${given}.\nRun grayspar project to see your projects.`, '--project');
51
+ }
52
+ if (matches.length === 1)
53
+ return only.project;
54
+ const team = await resolveTeam(api, { among: matches.map((match) => match.team) });
55
+ return matches.find((match) => match.team.id === team.id)?.project ?? only.project;
56
+ }
@@ -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 are not 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
+ }
@@ -0,0 +1,28 @@
1
+ import { color, table } from './ui.js';
2
+ const untinted = (text) => text;
3
+ const RUNTIME_STATUS = {
4
+ deploying: { label: 'Deploying', tint: untinted },
5
+ running: { label: 'Running', tint: color.ok },
6
+ restarting: { label: 'Restarting', tint: untinted },
7
+ crashed: { label: 'Crashed', tint: color.bad },
8
+ failed: { label: 'Failed', tint: color.bad },
9
+ };
10
+ export function servicesTable(project) {
11
+ const rows = project.services.map((service) => [
12
+ service.name,
13
+ service.pathPrefix === null
14
+ ? 'internal'
15
+ : service.pathPrefix === '/'
16
+ ? project.url
17
+ : `${project.url}${service.pathPrefix}`,
18
+ RUNTIME_STATUS[service.runtime].label,
19
+ ]);
20
+ return table(['SERVICE', 'URL', 'STATUS'], rows, (cell, column, row) => {
21
+ const service = project.services[row];
22
+ if (column === 1 && service?.pathPrefix !== null)
23
+ return color.link(cell);
24
+ if (column === 2 && service !== undefined)
25
+ return RUNTIME_STATUS[service.runtime].tint(cell);
26
+ return cell;
27
+ });
28
+ }
package/dist/lib/ui.js ADDED
@@ -0,0 +1,58 @@
1
+ /* One place owns how text output looks: every line starts at column 0, a result leads with ✓, ! or ✗ and still
2
+ reads without it, grey is kept for what sits beside an answer, and colour never reaches the JSON path or a pipe. */
3
+ import pc from 'picocolors';
4
+ export const color = {
5
+ heading: (text) => pc.bold(text),
6
+ dim: (text) => pc.dim(text),
7
+ ok: (text) => pc.green(text),
8
+ bad: (text) => pc.red(text),
9
+ link: (text) => pc.cyan(text),
10
+ };
11
+ export function receipt(message) {
12
+ return `${pc.green('✓')} ${message}`;
13
+ }
14
+ export function warning(message) {
15
+ return `${pc.yellow('!')} ${message}`;
16
+ }
17
+ export function failure(message) {
18
+ return `${pc.red('✗')} ${message}`;
19
+ }
20
+ export function table(header, rows, style = (cell) => cell) {
21
+ const widths = [];
22
+ for (const row of [header, ...rows]) {
23
+ row.forEach((cell, column) => {
24
+ widths[column] = Math.max(widths[column] ?? 0, cell.length);
25
+ });
26
+ }
27
+ const line = (cells, row) => cells
28
+ .map((cell, column) => {
29
+ const padded = column === cells.length - 1 ? cell : cell.padEnd(widths[column] ?? 0);
30
+ return row === null ? padded : style(padded, column, row);
31
+ })
32
+ .join(' ');
33
+ return [line(header, null), ...rows.map(line)].join('\n');
34
+ }
35
+ export function tableOrEmptyNote(header, rows, whenEmpty, style) {
36
+ return rows.length === 0 ? whenEmpty : table(header, rows, style);
37
+ }
38
+ const SPINNER_FRAMES = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
39
+ const CLEAR_LINE = '\r\x1b[2K';
40
+ export function spinner(text) {
41
+ let shown = text;
42
+ let frame = 0;
43
+ const draw = () => {
44
+ process.stderr.write(`${CLEAR_LINE}${pc.cyan(SPINNER_FRAMES.charAt(frame % SPINNER_FRAMES.length))} ${shown}`);
45
+ frame += 1;
46
+ };
47
+ draw();
48
+ const timer = setInterval(draw, 80);
49
+ return {
50
+ update: (next) => {
51
+ shown = next;
52
+ },
53
+ stop: () => {
54
+ clearInterval(timer);
55
+ process.stderr.write(CLEAR_LINE);
56
+ },
57
+ };
58
+ }
@@ -0,0 +1,12 @@
1
+ import { readFileSync } from 'node:fs';
2
+ function readVersion() {
3
+ const manifest = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
4
+ if (typeof manifest === 'object' &&
5
+ manifest !== null &&
6
+ 'version' in manifest &&
7
+ typeof manifest.version === 'string') {
8
+ return manifest.version;
9
+ }
10
+ throw new Error('The CLI\u2019s package.json has no version.');
11
+ }
12
+ export const cliVersion = readVersion();
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@grayspar/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.1",
4
+ "description": "The cloud for small software. Deploy your applications with one command, and share them with exactly the people you choose.",
5
+ "keywords": [
6
+ "grayspar",
7
+ "deploy",
8
+ "hosting",
9
+ "cloud",
10
+ "cli",
11
+ "private",
12
+ "sharing",
13
+ "access control"
14
+ ],
15
+ "homepage": "https://grayspar.com",
16
+ "bugs": {
17
+ "email": "support@grayspar.com"
18
+ },
19
+ "license": "Apache-2.0",
20
+ "type": "module",
21
+ "bin": {
22
+ "grayspar": "./dist/index.js"
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "engines": {
28
+ "node": ">=22"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "dependencies": {
34
+ "@inquirer/core": "10.3.2",
35
+ "@inquirer/select": "4.4.2",
36
+ "commander": "14.0.2",
37
+ "ignore": "7.0.10",
38
+ "openapi-fetch": "0.17.0",
39
+ "picocolors": "1.1.1",
40
+ "tar": "7.5.22"
41
+ },
42
+ "devDependencies": {
43
+ "@grayspar/api-types": "0.0.0"
44
+ },
45
+ "scripts": {
46
+ "prebuild": "pnpm --filter @grayspar/api generate",
47
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
48
+ "typecheck": "tsc --noEmit"
49
+ }
6
50
  }