@grayspar/cli 0.0.1 → 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.
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 +9 -6
  16. package/dist/commands/login/open-browser.js +2 -2
  17. package/dist/commands/login/sign-in-callback.js +5 -4
  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
package/README.md CHANGED
@@ -32,10 +32,10 @@ A service at the root of your directory is called `app`, and one in a subfolder
32
32
  ## Share
33
33
 
34
34
  ```sh
35
- grayspar access add alice@company.com
35
+ grayspar access add alice@company.example
36
36
  ```
37
37
 
38
- Alice gets an email with the link. Name people one by one, or a whole company at once with `@company.com`.
38
+ Alice gets an email with the link. Name people one by one, or a whole company at once with `@company.example`.
39
39
 
40
40
  ## For scripts and agents
41
41
 
@@ -59,7 +59,7 @@ Every command answers in JSON with `--json`, and `--yes` accepts what the CLI wo
59
59
  | `unlink` | unbind this folder from its project |
60
60
  | `status` | show what a project is doing |
61
61
  | `logs` | show what a service printed while running, or with `--build` the last build log |
62
- | `restart` | run a service again in place, keeping its volumes |
62
+ | `restart` | run a service again in place, keeping its volume |
63
63
 
64
64
  `grayspar <command> --help` shows each command's options.
65
65
 
@@ -25,13 +25,13 @@ function addedReceipt(added, name) {
25
25
  return receipt(`Added @${added.entry.domain} to ${name}`);
26
26
  return added.told === true
27
27
  ? receipt(`Added ${added.entry.email} to ${name} and emailed the link`)
28
- : warning(`Added ${added.entry.email} to ${name}, but the email did not send.`);
28
+ : warning(`Added ${added.entry.email} to ${name}, but the email didn't send.`);
29
29
  }
30
30
  async function setGeneralAccess(mode, format) {
31
31
  const api = await signedInClient();
32
32
  const project = await resolveProject(api);
33
- const access = await dataOrThrow(api.PUT('/api/projects/{id}/general-access', {
34
- params: { path: { id: project.id } },
33
+ const access = await dataOrThrow(api.PUT('/api/projects/{projectId}/general-access', {
34
+ params: { path: { projectId: project.id } },
35
35
  body: { mode },
36
36
  }));
37
37
  printResult(format, access, () => `${receipt(`Set access of ${project.name} to ${access.mode}`)}\n${accessSentence(access.mode, project.name)}`);
@@ -41,8 +41,8 @@ async function addAccess(targets, format) {
41
41
  const project = await resolveProject(api);
42
42
  const results = [];
43
43
  for (const target of targets) {
44
- results.push(await dataOrThrow(api.POST('/api/projects/{id}/access', {
45
- params: { path: { id: project.id } },
44
+ results.push(await dataOrThrow(api.POST('/api/projects/{projectId}/access', {
45
+ params: { path: { projectId: project.id } },
46
46
  body: targetOf(target),
47
47
  })));
48
48
  }
@@ -51,7 +51,7 @@ async function addAccess(targets, format) {
51
51
  async function listAccess(format) {
52
52
  const api = await signedInClient();
53
53
  const project = await resolveProject(api);
54
- const rows = await dataOrThrow(api.GET('/api/projects/{id}/access', { params: { path: { id: project.id } } }));
54
+ const rows = await dataOrThrow(api.GET('/api/projects/{projectId}/access', { params: { path: { projectId: project.id } } }));
55
55
  printResult(format, rows, () => [
56
56
  accessSentence(project.generalAccess.mode, project.name),
57
57
  '',
@@ -68,14 +68,14 @@ async function listAccess(format) {
68
68
  async function removeAccess(target, format) {
69
69
  const api = await signedInClient();
70
70
  const project = await resolveProject(api);
71
- const rows = await dataOrThrow(api.GET('/api/projects/{id}/access', { params: { path: { id: project.id } } }));
71
+ const rows = await dataOrThrow(api.GET('/api/projects/{projectId}/access', { params: { path: { projectId: project.id } } }));
72
72
  const wanted = target.toLowerCase();
73
73
  const row = rows.find((candidate) => labelOf(candidate) === wanted);
74
74
  if (row === undefined) {
75
- throw new CliError('not_shared', `${target} is not on the list for ${project.name}.`);
75
+ throw new CliError('not_shared', `${target} isn't on the list for ${project.name}.`);
76
76
  }
77
- await dataOrThrow(api.DELETE('/api/projects/{id}/access/{accessId}', {
78
- params: { path: { id: project.id, accessId: row.id } },
77
+ await dataOrThrow(api.DELETE('/api/projects/{projectId}/access/{accessId}', {
78
+ params: { path: { projectId: project.id, accessId: row.id } },
79
79
  }));
80
80
  printResult(format, { status: 'ok', removed: labelOf(row) }, () => receipt(`Removed ${labelOf(row)} from ${project.name}`));
81
81
  }
@@ -86,12 +86,12 @@ accessCommand
86
86
  .action(commandAction((format) => listAccess(format)));
87
87
  accessCommand
88
88
  .command('add')
89
- .argument('<targets...>', 'email addresses, or @company.com for everyone at a company')
89
+ .argument('<targets...>', 'email addresses, or @company.example for everyone at a company')
90
90
  .description('let named 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
- .argument('<target>', 'an email address, or @company.com')
94
+ .argument('<target>', 'an email address, or @company.example')
95
95
  .description('take someone or a company off a project')
96
96
  .action(commandAction((format, _command, target) => removeAccess(target, format)));
97
97
  accessCommand
@@ -1,23 +1,69 @@
1
- /* What git ignores stays home, matched by git's own rules in every folder's .gitignore, and a .env never goes in
2
- the archive, because a value landing in an image layer would sit there for as long as the image exists. */
3
- import { readFileSync } from 'node:fs';
4
- import { join } from 'node:path';
1
+ /* Git lists what to upload inside a work tree, and the .gitignore files decide elsewhere. A .env never goes in the
2
+ archive, because a value landing in an image layer would sit there for as long as the image exists. */
3
+ import { execFile } from 'node:child_process';
4
+ import { createHash } from 'node:crypto';
5
+ import { createReadStream, createWriteStream, readFileSync } from 'node:fs';
6
+ import { lstat, stat } from 'node:fs/promises';
7
+ import { basename, join } from 'node:path';
8
+ import { Readable, Transform } from 'node:stream';
9
+ import { pipeline } from 'node:stream/promises';
10
+ import { promisify } from 'node:util';
5
11
  import ignore, {} from 'ignore';
6
12
  import { create } from 'tar';
7
- const ALWAYS_SKIPPED = [
8
- 'node_modules',
9
- '.git',
10
- 'dist',
11
- 'build',
12
- 'out',
13
- 'target',
14
- 'vendor',
15
- '.venv',
16
- '.next',
17
- '.turbo',
18
- 'coverage',
19
- ];
20
- const ENV_FILE_PATTERN = /(^|\/)\.env(\..+)?$/;
13
+ import { CliError } from '../../lib/errors.js';
14
+ const execFileAsync = promisify(execFile);
15
+ const GIT_LIST_TIMEOUT_MS = 60_000;
16
+ const GIT_LIST_MAX_BYTES = 256 * 1024 * 1024;
17
+ const GIT_NOT_A_WORK_TREE_EXIT_CODE = 128;
18
+ const OUTSIDE_GIT_SKIPPED = ['node_modules', '.git'];
19
+ const LINK_FOLDER_PATTERN = /(^|\/)\.grayspar(\/|$)/;
20
+ function isKeptOutOfUpload(path) {
21
+ return basename(path).startsWith('.env') || LINK_FOLDER_PATTERN.test(path);
22
+ }
23
+ async function listWithGit(root, ...args) {
24
+ const { stdout } = await execFileAsync('git', ['--literal-pathspecs', 'ls-files', '-z', ...args], { cwd: root, timeout: GIT_LIST_TIMEOUT_MS, maxBuffer: GIT_LIST_MAX_BYTES });
25
+ return stdout.split('\0').filter((path) => path !== '');
26
+ }
27
+ async function presentFiles(root, listed) {
28
+ const found = await Promise.all(listed.map(async (path) => ({ path, entry: await lstat(join(root, path)).catch(() => null) })));
29
+ const present = found.flatMap(({ path, entry }) => (entry === null ? [] : [{ path, entry }]));
30
+ const folder = present.find(({ entry }) => entry.isDirectory());
31
+ if (folder !== undefined)
32
+ throw new FolderListed(folder.path.replace(/\/$/, ''));
33
+ return present.map(({ path }) => path);
34
+ }
35
+ class FolderListed extends Error {
36
+ }
37
+ async function filesGitKeeps(root, under) {
38
+ let tracked;
39
+ let untracked;
40
+ try {
41
+ // git refuses --recurse-submodules together with --others, so the untracked files come from a second listing
42
+ tracked = await listWithGit(root, '--cached', '--recurse-submodules', '--', under);
43
+ untracked = await listWithGit(root, '--others', '--exclude-standard', '--', under);
44
+ }
45
+ catch (error) {
46
+ const notGit = error instanceof Error &&
47
+ 'code' in error &&
48
+ (error.code === GIT_NOT_A_WORK_TREE_EXIT_CODE || error.code === 'ENOENT');
49
+ if (notGit)
50
+ return null;
51
+ throw error;
52
+ }
53
+ try {
54
+ const trackedFiles = await presentFiles(root, tracked);
55
+ const untrackedFiles = await presentFiles(root, untracked);
56
+ return [...new Set([...trackedFiles, ...untrackedFiles])];
57
+ }
58
+ catch (error) {
59
+ if (!(error instanceof FolderListed))
60
+ throw error;
61
+ // a folder listed as tracked is a submodule nobody checked out, and one listed as untracked holds a repository of its own
62
+ throw tracked.some((path) => path.replace(/\/$/, '') === error.message)
63
+ ? new CliError('submodule_not_checked_out', `${error.message} is a submodule that isn't checked out.\nRun git submodule update --init, then deploy again.`)
64
+ : new CliError('nested_repository', `${error.message} is a separate git repository, so its files can't be listed.\nAdd it to .gitignore, or make its files part of this repository.`);
65
+ }
66
+ }
21
67
  function readOrEmpty(path) {
22
68
  try {
23
69
  return readFileSync(path, 'utf8');
@@ -28,9 +74,8 @@ function readOrEmpty(path) {
28
74
  }
29
75
  function ignoredBy(root) {
30
76
  const rootRules = ignore()
31
- .add(ALWAYS_SKIPPED)
32
- .add(readOrEmpty(join(root, '.gitignore')))
33
- .add(readOrEmpty(join(root, '.dockerignore')));
77
+ .add(OUTSIDE_GIT_SKIPPED)
78
+ .add(readOrEmpty(join(root, '.gitignore')));
34
79
  const nested = new Map();
35
80
  function rulesIn(folder) {
36
81
  if (!nested.has(folder)) {
@@ -41,7 +86,7 @@ function ignoredBy(root) {
41
86
  }
42
87
  return (path, isDirectory) => {
43
88
  const asked = isDirectory ? `${path}/` : path;
44
- if (ENV_FILE_PATTERN.test(path) || rootRules.ignores(asked))
89
+ if (rootRules.ignores(asked))
45
90
  return true;
46
91
  const folders = path.split('/').slice(0, -1);
47
92
  return folders.some((_, index) => {
@@ -50,18 +95,62 @@ function ignoredBy(root) {
50
95
  });
51
96
  };
52
97
  }
53
- export async function archiveOf(root) {
54
- const ignored = ignoredBy(root);
55
- const chunks = [];
56
- const stream = create({
57
- cwd: root,
58
- gzip: true,
59
- filter: (path, entry) => {
60
- const relative = path.replace(/^\.\/?/, '');
61
- return relative === '' || !ignored(relative, 'isDirectory' in entry && entry.isDirectory());
98
+ const BYTES_PER_MEGABYTE = 1024 ** 2;
99
+ const FIX_BY_IGNORING = "Add the files you don't need, such as build output, to .gitignore.";
100
+ function tooManyFiles({ files }) {
101
+ return new CliError('too_many_files', `A deploy holds at most ${files.toLocaleString('en-US')} files, and this project has more.\n${FIX_BY_IGNORING}`);
102
+ }
103
+ function stopsPast({ bytes }) {
104
+ let written = 0;
105
+ return new Transform({
106
+ transform(chunk, _encoding, done) {
107
+ written += chunk.length;
108
+ done(written > bytes
109
+ ? new CliError('upload_too_large', `A deploy can be at most ${Math.floor(bytes / BYTES_PER_MEGABYTE)} MB, and this project is larger.\n${FIX_BY_IGNORING}`)
110
+ : null, chunk);
62
111
  },
63
- }, ['.']);
64
- for await (const chunk of stream)
65
- chunks.push(Buffer.from(chunk));
66
- return Buffer.concat(chunks);
112
+ });
113
+ }
114
+ function refuseEmptyUpload(root, under) {
115
+ throw new CliError('nothing_to_upload', `There are no files to deploy in ${join(root, under)}.`);
116
+ }
117
+ export async function archiveOf(root, under, into, limits) {
118
+ const path = join(into, 'archive.tgz');
119
+ const files = await filesGitKeeps(root, under);
120
+ const write = (packed) => pipeline(Readable.from(packed), stopsPast(limits), createWriteStream(path));
121
+ if (files === null) {
122
+ const ignored = ignoredBy(root);
123
+ let fileCount = 0;
124
+ await write(create({
125
+ cwd: root,
126
+ gzip: true,
127
+ filter: (entryPath, entry) => {
128
+ const relative = entryPath.replace(/^\.\/?/, '');
129
+ if (relative === '')
130
+ return true;
131
+ const isDirectory = 'isDirectory' in entry && entry.isDirectory();
132
+ if (isKeptOutOfUpload(relative) || ignored(relative, isDirectory))
133
+ return false;
134
+ if (!isDirectory)
135
+ fileCount += 1;
136
+ return fileCount <= limits.files;
137
+ },
138
+ }, [under]));
139
+ if (fileCount === 0)
140
+ refuseEmptyUpload(root, under);
141
+ if (fileCount > limits.files)
142
+ throw tooManyFiles(limits);
143
+ }
144
+ else {
145
+ const kept = files.filter((file) => !isKeptOutOfUpload(file));
146
+ if (kept.length === 0)
147
+ refuseEmptyUpload(root, under);
148
+ if (kept.length > limits.files)
149
+ throw tooManyFiles(limits);
150
+ await write(create({ cwd: root, gzip: true, noDirRecurse: true }, kept));
151
+ }
152
+ const hash = createHash('sha256');
153
+ for await (const chunk of createReadStream(path))
154
+ hash.update(chunk);
155
+ return { path, bytes: (await stat(path)).size, sha256: hash.digest('hex') };
67
156
  }
@@ -0,0 +1,17 @@
1
+ import { dataOrThrow } from '../../lib/client.js';
2
+ import { realClock, retryTransient } from '../../lib/retry-transient.js';
3
+ export async function awaitDiscovery(api, destination, uploadId, clock = realClock) {
4
+ for (;;) {
5
+ const watched = await retryTransient((signal) => dataOrThrow(destination.kind === 'project'
6
+ ? api.GET('/api/projects/{projectId}/uploads/{uploadId}/discovery', {
7
+ params: { path: { projectId: destination.project.id, uploadId } },
8
+ signal,
9
+ })
10
+ : api.GET('/api/teams/{teamId}/uploads/{uploadId}/discovery', {
11
+ params: { path: { teamId: destination.team.id, uploadId } },
12
+ signal,
13
+ })), clock);
14
+ if (watched !== undefined)
15
+ return watched;
16
+ }
17
+ }
@@ -0,0 +1,101 @@
1
+ import { basename } from 'node:path';
2
+ import { dataOrThrow } from '../../lib/client.js';
3
+ import { CliError } from '../../lib/errors.js';
4
+ import { printNote } from '../../lib/output.js';
5
+ import { askChoice, askText, isInteractive } from '../../lib/prompt.js';
6
+ import { projectIsNamed, resolveProject, teamProjects } from '../../lib/resolve-project.js';
7
+ import { resolveTeam } from '../../lib/resolve-team.js';
8
+ import { failure } from '../../lib/ui.js';
9
+ import { projectNameFrom } from './project-name.js';
10
+ export function destinationOptions(projects, suggestedName) {
11
+ const match = projects.find((project) => project.name === suggestedName);
12
+ const others = projects
13
+ .filter((project) => project !== match)
14
+ .map((project) => ({ kind: 'existing', project }));
15
+ const create = { kind: 'create' };
16
+ return match === undefined
17
+ ? [create, ...others]
18
+ : [{ kind: 'existing', project: match }, create, ...others];
19
+ }
20
+ const NAME_RULE = 'Names use lowercase letters, numbers and single hyphens, up to 58 characters.';
21
+ function nameProblem(name, projects) {
22
+ if (projectNameFrom(name) !== name)
23
+ return NAME_RULE;
24
+ return projects.some((project) => project.name === name)
25
+ ? `This team already has a project called ${name}.`
26
+ : null;
27
+ }
28
+ function givenName(name, projects) {
29
+ const problem = name === null ? NAME_RULE : nameProblem(name, projects);
30
+ if (name === null || problem !== null) {
31
+ throw new CliError('needs_a_name', `${problem ?? NAME_RULE}\nName the project with --name.`, '--name');
32
+ }
33
+ return name;
34
+ }
35
+ async function askProjectName(projects, suggestion) {
36
+ let offered = suggestion;
37
+ for (;;) {
38
+ const answer = await askText('Project name', offered, '--name');
39
+ const problem = nameProblem(answer, projects);
40
+ if (problem === null)
41
+ return answer;
42
+ process.stdout.write(`${failure(problem)}\n`);
43
+ offered = projectNameFrom(answer);
44
+ }
45
+ }
46
+ export async function destinationFor(api, root, link, options) {
47
+ if (options.name !== undefined && projectIsNamed()) {
48
+ throw new CliError('name_and_project', '--name creates a new project and --project names an existing one.\nGive one of them.', '--name');
49
+ }
50
+ if (projectIsNamed()) {
51
+ return {
52
+ kind: 'project',
53
+ project: await resolveProject(api, { followLink: false }),
54
+ linksFolder: false,
55
+ };
56
+ }
57
+ if (link !== null) {
58
+ const answer = await api.GET('/api/projects/{projectId}', {
59
+ params: { path: { projectId: link.projectId } },
60
+ });
61
+ if (answer.response.status !== 404) {
62
+ if (options.name !== undefined) {
63
+ throw new CliError('already_linked', 'This folder is already linked to a project.\nRun grayspar unlink to deploy it as a new one.', '--name');
64
+ }
65
+ return {
66
+ kind: 'project',
67
+ project: await dataOrThrow(Promise.resolve(answer)),
68
+ linksFolder: false,
69
+ };
70
+ }
71
+ printNote(options.format, 'The project this folder was linked to no longer exists.');
72
+ }
73
+ const team = await resolveTeam(api, { takeDefault: options.yes, deploying: true });
74
+ const projects = await teamProjects(api, team.id);
75
+ if (options.name !== undefined) {
76
+ return { kind: 'new', team, name: givenName(options.name, projects) };
77
+ }
78
+ const suggestion = projectNameFrom(basename(root));
79
+ const destinations = destinationOptions(projects, suggestion);
80
+ const [suggested] = destinations;
81
+ if (suggested === undefined)
82
+ throw new Error('no project to create');
83
+ if (options.yes) {
84
+ return suggested.kind === 'existing'
85
+ ? { kind: 'project', project: suggested.project, linksFolder: true }
86
+ : { kind: 'new', team, name: givenName(suggestion, projects) };
87
+ }
88
+ if (!isInteractive()) {
89
+ throw new CliError('needs_an_answer', "This folder isn't linked to a project yet, and there's no terminal to ask which.\nDeploy to a new project with --name, or to an existing one with --project.", '--name');
90
+ }
91
+ const picked = await askChoice('Select a project', 'Project', destinations.map((option, index) => ({
92
+ value: String(index),
93
+ label: option.kind === 'create' ? 'Create a new project' : option.project.name,
94
+ })), '--project');
95
+ const choice = destinations[Number(picked)] ?? suggested;
96
+ const destination = choice.kind === 'existing'
97
+ ? { kind: 'project', project: choice.project, linksFolder: true }
98
+ : { kind: 'new', team, name: await askProjectName(projects, suggestion) };
99
+ process.stdout.write('\n');
100
+ return destination;
101
+ }
@@ -4,6 +4,7 @@ const EXAMPLE_VALUE = {
4
4
  '--dockerfile': 'docker/Dockerfile',
5
5
  '--volume': '/app/data',
6
6
  };
7
+ export const NO_PATH_VALUE = 'none';
7
8
  function servicePairs(values, flag, onlyServices) {
8
9
  return values.map((value) => {
9
10
  const separatorIndex = value.indexOf('=');
@@ -20,7 +21,10 @@ function servicePairs(values, flag, onlyServices) {
20
21
  export function parseDeployFlags(flags) {
21
22
  const volumes = new Map();
22
23
  for (const [name, path] of servicePairs(flags.volumes, '--volume', flags.services)) {
23
- volumes.set(name, [...(volumes.get(name) ?? []), path]);
24
+ if (volumes.has(name)) {
25
+ throw new CliError('bad_flag', `A service keeps one volume. Give --volume once for ${name}.`, '--volume');
26
+ }
27
+ volumes.set(name, path);
24
28
  }
25
29
  return {
26
30
  onlyServices: flags.services,
@@ -43,12 +47,14 @@ export function servicesToSend(found, serviceFlags) {
43
47
  return deploying.map((name) => {
44
48
  const pathPrefix = serviceFlags.paths.get(name);
45
49
  const dockerfile = serviceFlags.dockerfiles.get(name);
46
- const volumes = serviceFlags.volumes.get(name);
50
+ const volume = serviceFlags.volumes.get(name);
47
51
  return {
48
52
  name,
49
- ...(pathPrefix === undefined ? {} : { pathPrefix }),
53
+ ...(pathPrefix === undefined
54
+ ? {}
55
+ : { pathPrefix: pathPrefix === NO_PATH_VALUE ? null : pathPrefix }),
50
56
  ...(dockerfile === undefined ? {} : { dockerfile }),
51
- ...(volumes === undefined ? {} : { volumes }),
57
+ ...(volume === undefined ? {} : { volume }),
52
58
  };
53
59
  });
54
60
  }
@@ -0,0 +1,19 @@
1
+ /* The project folder is the linked one found from where the command runs, or the current folder when nothing is
2
+ linked. A folder given to deploy narrows the upload to the files under it, which keep their place in that folder. */
3
+ import { stat } from 'node:fs/promises';
4
+ import { isAbsolute, relative, resolve } from 'node:path';
5
+ import { CliError } from '../../lib/errors.js';
6
+ import { findLink } from '../../lib/project-link.js';
7
+ export async function deployTarget(currentFolder, folder) {
8
+ const link = await findLink(currentFolder);
9
+ const root = link?.folder ?? resolve(currentFolder);
10
+ const wanted = resolve(currentFolder, folder);
11
+ const under = relative(root, wanted) || '.';
12
+ if (under === '..' || under.startsWith('../') || isAbsolute(under)) {
13
+ throw new CliError('outside_project', `${folder} is outside the project folder ${root}.\nRun grayspar deploy from inside the project folder.`);
14
+ }
15
+ const isFolder = await stat(wanted).then((found) => found.isDirectory(), () => false);
16
+ if (!isFolder)
17
+ throw new CliError('no_such_folder', `${folder} isn't a folder.`);
18
+ return { link, root, under };
19
+ }