@grayspar/cli 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/commands/access.js +12 -12
- package/dist/commands/deploy/archive.js +124 -35
- 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 +10 -4
- package/dist/commands/deploy/deploy-target.js +19 -0
- package/dist/commands/deploy/deploy.js +119 -276
- package/dist/commands/deploy/env-files.js +7 -23
- package/dist/commands/deploy/git-state.js +19 -6
- package/dist/commands/deploy/submit-deploy.js +127 -0
- package/dist/commands/deploy/variables-to-send.js +83 -12
- package/dist/commands/env.js +13 -12
- package/dist/commands/link.js +6 -5
- package/dist/commands/login/login.js +11 -6
- package/dist/commands/login/open-browser.js +2 -2
- package/dist/commands/login/sign-in-callback.js +28 -8
- package/dist/commands/logout.js +5 -17
- package/dist/commands/logs.js +23 -18
- package/dist/commands/open-path.js +4 -4
- package/dist/commands/restart.js +7 -5
- package/dist/commands/service.js +16 -25
- package/dist/commands/volume.js +33 -19
- package/dist/index.js +6 -1
- package/dist/lib/client.js +33 -7
- package/dist/lib/credentials.js +14 -3
- package/dist/lib/follow-deployment.js +79 -51
- package/dist/lib/output.js +6 -2
- package/dist/lib/project-link.js +2 -5
- package/dist/lib/prompt.js +43 -5
- package/dist/lib/resolve-project.js +6 -2
- package/dist/lib/resolve-team.js +1 -1
- package/dist/lib/retry-transient.js +32 -0
- package/dist/lib/services-table.js +1 -0
- package/dist/lib/usage-errors.js +43 -0
- package/dist/lib/version.js +1 -1
- package/package.json +6 -1
- 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.
|
|
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.
|
|
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
|
|
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
|
|
package/dist/commands/access.js
CHANGED
|
@@ -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
|
|
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/{
|
|
34
|
-
params: { path: {
|
|
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/{
|
|
45
|
-
params: { path: {
|
|
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/{
|
|
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/{
|
|
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}
|
|
75
|
+
throw new CliError('not_shared', `${target} isn't on the list for ${project.name}.`);
|
|
76
76
|
}
|
|
77
|
-
await dataOrThrow(api.DELETE('/api/projects/{
|
|
78
|
-
params: { path: {
|
|
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.
|
|
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.
|
|
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
|
-
/*
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
'.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
'
|
|
19
|
-
|
|
20
|
-
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
50
|
+
const volume = serviceFlags.volumes.get(name);
|
|
47
51
|
return {
|
|
48
52
|
name,
|
|
49
|
-
...(pathPrefix === undefined
|
|
53
|
+
...(pathPrefix === undefined
|
|
54
|
+
? {}
|
|
55
|
+
: { pathPrefix: pathPrefix === NO_PATH_VALUE ? null : pathPrefix }),
|
|
50
56
|
...(dockerfile === undefined ? {} : { dockerfile }),
|
|
51
|
-
...(
|
|
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
|
+
}
|