@grayspar/cli 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) 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 +156 -0
  5. package/dist/commands/deploy/await-discovery.js +17 -0
  6. package/dist/commands/deploy/deploy-destination.js +101 -0
  7. package/dist/commands/deploy/deploy-flags.js +60 -0
  8. package/dist/commands/deploy/deploy-target.js +19 -0
  9. package/dist/commands/deploy/deploy.js +209 -0
  10. package/dist/commands/deploy/env-files.js +47 -0
  11. package/dist/commands/deploy/git-state.js +35 -0
  12. package/dist/commands/deploy/project-name.js +13 -0
  13. package/dist/commands/deploy/submit-deploy.js +127 -0
  14. package/dist/commands/deploy/variables-to-send.js +140 -0
  15. package/dist/commands/env.js +71 -0
  16. package/dist/commands/link.js +28 -0
  17. package/dist/commands/login/challenge.js +5 -0
  18. package/dist/commands/login/login.js +59 -0
  19. package/dist/commands/login/open-browser.js +15 -0
  20. package/dist/commands/login/sign-in-callback.js +37 -0
  21. package/dist/commands/logout.js +18 -0
  22. package/dist/commands/logs.js +89 -0
  23. package/dist/commands/open-path.js +45 -0
  24. package/dist/commands/project.js +22 -0
  25. package/dist/commands/restart.js +21 -0
  26. package/dist/commands/service.js +63 -0
  27. package/dist/commands/status.js +15 -0
  28. package/dist/commands/volume.js +87 -0
  29. package/dist/commands/whoami.js +16 -0
  30. package/dist/index.js +58 -0
  31. package/dist/lib/client.js +71 -0
  32. package/dist/lib/command-helpers.js +25 -0
  33. package/dist/lib/credentials.js +49 -0
  34. package/dist/lib/errors.js +12 -0
  35. package/dist/lib/follow-deployment.js +127 -0
  36. package/dist/lib/output.js +40 -0
  37. package/dist/lib/project-link.js +42 -0
  38. package/dist/lib/prompt.js +103 -0
  39. package/dist/lib/resolve-project.js +60 -0
  40. package/dist/lib/resolve-service.js +29 -0
  41. package/dist/lib/resolve-team.js +31 -0
  42. package/dist/lib/retry-transient.js +32 -0
  43. package/dist/lib/services-table.js +29 -0
  44. package/dist/lib/ui.js +58 -0
  45. package/dist/lib/usage-errors.js +43 -0
  46. package/dist/lib/version.js +12 -0
  47. package/package.json +52 -3
@@ -0,0 +1,60 @@
1
+ import { CliError } from '../../lib/errors.js';
2
+ const EXAMPLE_VALUE = {
3
+ '--path': '/api',
4
+ '--dockerfile': 'docker/Dockerfile',
5
+ '--volume': '/app/data',
6
+ };
7
+ export const NO_PATH_VALUE = 'none';
8
+ function servicePairs(values, flag, onlyServices) {
9
+ return values.map((value) => {
10
+ const separatorIndex = value.indexOf('=');
11
+ if (separatorIndex < 1) {
12
+ throw new CliError('bad_flag', `Write ${flag} as a service and a value, like ${flag} backend=${EXAMPLE_VALUE[flag]}.`, flag);
13
+ }
14
+ const name = value.slice(0, separatorIndex);
15
+ if (onlyServices.length > 0 && !onlyServices.includes(name)) {
16
+ throw new CliError('bad_flag', `${flag} ${value} is for ${name}, which --service leaves out. Add --service ${name}.`, '--service');
17
+ }
18
+ return [name, value.slice(separatorIndex + 1)];
19
+ });
20
+ }
21
+ export function parseDeployFlags(flags) {
22
+ const volumes = new Map();
23
+ for (const [name, path] of servicePairs(flags.volumes, '--volume', flags.services)) {
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);
28
+ }
29
+ return {
30
+ onlyServices: flags.services,
31
+ paths: new Map(servicePairs(flags.paths, '--path', flags.services)),
32
+ dockerfiles: new Map(servicePairs(flags.dockerfiles, '--dockerfile', flags.services)),
33
+ volumes,
34
+ };
35
+ }
36
+ export function servicesToSend(found, serviceFlags) {
37
+ const deploying = serviceFlags.onlyServices.length > 0
38
+ ? serviceFlags.onlyServices
39
+ : [
40
+ ...new Set([
41
+ ...found,
42
+ ...serviceFlags.paths.keys(),
43
+ ...serviceFlags.dockerfiles.keys(),
44
+ ...serviceFlags.volumes.keys(),
45
+ ]),
46
+ ];
47
+ return deploying.map((name) => {
48
+ const pathPrefix = serviceFlags.paths.get(name);
49
+ const dockerfile = serviceFlags.dockerfiles.get(name);
50
+ const volume = serviceFlags.volumes.get(name);
51
+ return {
52
+ name,
53
+ ...(pathPrefix === undefined
54
+ ? {}
55
+ : { pathPrefix: pathPrefix === NO_PATH_VALUE ? null : pathPrefix }),
56
+ ...(dockerfile === undefined ? {} : { dockerfile }),
57
+ ...(volume === undefined ? {} : { volume }),
58
+ };
59
+ });
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
+ }
@@ -0,0 +1,209 @@
1
+ /* Upload the project, send the deploy and follow it until it is live or has failed. */
2
+ import { openAsBlob, rmSync } from 'node:fs';
3
+ import { mkdtemp, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { Command, Option } from 'commander';
7
+ import { dataOrThrow, signedInClient } from '../../lib/client.js';
8
+ import { collectRepeatedValue, commandAction } from '../../lib/command-helpers.js';
9
+ import { CliError } from '../../lib/errors.js';
10
+ import { awaitDeployment, printDeployFailure, printReplacedDeploy, } from '../../lib/follow-deployment.js';
11
+ import { startProgress } from '../../lib/output.js';
12
+ import { writeLink } from '../../lib/project-link.js';
13
+ import { CTRL_C_EXIT_CODE } from '../../lib/prompt.js';
14
+ import { commandFor } from '../../lib/resolve-project.js';
15
+ import { servicesTable } from '../../lib/services-table.js';
16
+ import { color, receipt, table, warning } from '../../lib/ui.js';
17
+ import { archiveOf } from './archive.js';
18
+ import { awaitDiscovery } from './await-discovery.js';
19
+ import { destinationFor } from './deploy-destination.js';
20
+ import { NO_PATH_VALUE, parseDeployFlags, servicesToSend, } from './deploy-flags.js';
21
+ import { deployTarget } from './deploy-target.js';
22
+ import { emptyFoundVariables, variablesFor } from './env-files.js';
23
+ import { gitStateOf } from './git-state.js';
24
+ import { submitDeploy } from './submit-deploy.js';
25
+ import { variablesToSend } from './variables-to-send.js';
26
+ const UPLOAD_TIMEOUT_MS = 30 * 60 * 1000;
27
+ async function deploy(folder, options) {
28
+ const { link, root, under } = await deployTarget(process.cwd(), folder);
29
+ const serviceFlags = parseDeployFlags(options.flags);
30
+ const api = await signedInClient();
31
+ const destination = await destinationFor(api, root, link, options);
32
+ const inspecting = startProgress(options.format, 'Inspecting your project…');
33
+ const uploaded = await uploadSource(api, destination, { root, under }, inspecting).finally(inspecting.stop);
34
+ const variables = new Map(await Promise.all(uploaded.services.map(async (service) => [
35
+ service.name,
36
+ options.env
37
+ ? await variablesFor(root, service.rootFolder, uploaded.platformVariables, options.envFile)
38
+ : emptyFoundVariables(),
39
+ ])));
40
+ if (options.format === 'text')
41
+ printFoundServices(uploaded, variables, under === '.');
42
+ const foundNames = uploaded.services.map((service) => service.name);
43
+ const deploying = servicesToSend(foundNames, serviceFlags).flatMap(({ name }) => {
44
+ const known = variables.get(name);
45
+ return known === undefined ? [] : [{ name, foundVariables: known }];
46
+ });
47
+ const chosen = await variablesToSend(api, destination.kind === 'project' ? destination.project : null, root, deploying.map((service) => service.name), deploying.map((service) => service.foundVariables), options.envConflict, {
48
+ projectName: destination.kind === 'project' ? destination.project.name : destination.name,
49
+ format: options.format,
50
+ yes: options.yes,
51
+ });
52
+ const { project, deployment } = await submitDeploy(api, destination, options, serviceFlags, foundNames, { upload: uploaded.upload, variables: chosen.variables });
53
+ if (destination.kind === 'new' || destination.linksFolder) {
54
+ await writeLink(root, { projectId: project.id });
55
+ }
56
+ if (options.format === 'text') {
57
+ for (const { service, addedVolume } of deployment.services) {
58
+ if (addedVolume === null)
59
+ continue;
60
+ process.stdout.write(`${receipt(`Added volume ${addedVolume} to ${service}`)}\n`);
61
+ }
62
+ }
63
+ const outcome = await watchDeployment(api, project, deployment.id, options.format);
64
+ if (options.format === 'json') {
65
+ const variables = {
66
+ sent: Object.entries(chosen.variables).flatMap(([service, values]) => Object.keys(values).map((key) => ({ service, key }))),
67
+ leftOut: chosen.leftOut.map(({ service, key }) => ({ service, key })),
68
+ };
69
+ process.stdout.write(`${JSON.stringify({ project: project.name, url: project.url, variables, ...outcome }, null, 2)}\n`);
70
+ if (outcome.status !== 'live')
71
+ process.exitCode = 1;
72
+ return;
73
+ }
74
+ await printOutcome(api, project, outcome);
75
+ }
76
+ async function uploadLimitsOf(api, destination) {
77
+ if (destination.kind === 'new')
78
+ return destination.team.uploadLimits;
79
+ const teams = await dataOrThrow(api.GET('/api/teams'));
80
+ const team = teams.find((candidate) => candidate.id === destination.project.teamId);
81
+ if (team === undefined)
82
+ throw new Error(`team ${destination.project.teamId} is not in /api/teams`);
83
+ return team.uploadLimits;
84
+ }
85
+ async function uploadSource(api, destination, source, progress) {
86
+ const scratch = await mkdtemp(join(tmpdir(), 'grayspar-'));
87
+ const removeScratchAndExit = () => {
88
+ rmSync(scratch, { recursive: true, force: true });
89
+ process.exit(CTRL_C_EXIT_CODE);
90
+ };
91
+ process.once('SIGINT', removeScratchAndExit);
92
+ try {
93
+ const archive = await archiveOf(source.root, source.under, scratch, await uploadLimitsOf(api, destination));
94
+ const gitState = await gitStateOf(join(source.root, source.under));
95
+ const body = {
96
+ bytes: archive.bytes,
97
+ sha256: archive.sha256,
98
+ ...(gitState === null ? {} : { gitMetadata: gitState }),
99
+ };
100
+ const created = await dataOrThrow(destination.kind === 'project'
101
+ ? api.POST('/api/projects/{projectId}/uploads', {
102
+ params: { path: { projectId: destination.project.id } },
103
+ body,
104
+ })
105
+ : api.POST('/api/teams/{teamId}/uploads', {
106
+ params: { path: { teamId: destination.team.id } },
107
+ body,
108
+ }));
109
+ progress.update('Uploading…');
110
+ const stored = await fetch(created.url, {
111
+ method: 'PUT',
112
+ body: await openAsBlob(archive.path),
113
+ headers: created.headers,
114
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
115
+ }).catch(() => null);
116
+ if (stored?.ok !== true) {
117
+ throw new CliError('upload_failed', "Your project didn't finish uploading. Deploy again.");
118
+ }
119
+ progress.update('Inspecting your project…');
120
+ await dataOrThrow(destination.kind === 'project'
121
+ ? api.POST('/api/projects/{projectId}/uploads/{uploadId}/discover', {
122
+ params: { path: { projectId: destination.project.id, uploadId: created.upload } },
123
+ })
124
+ : api.POST('/api/teams/{teamId}/uploads/{uploadId}/discover', {
125
+ params: { path: { teamId: destination.team.id, uploadId: created.upload } },
126
+ }));
127
+ return await awaitDiscovery(api, destination, created.upload);
128
+ }
129
+ finally {
130
+ process.off('SIGINT', removeScratchAndExit);
131
+ await rm(scratch, { recursive: true, force: true });
132
+ }
133
+ }
134
+ function printFoundServices(uploaded, variables, isWholeProject) {
135
+ const count = uploaded.services.length;
136
+ const rows = uploaded.services.map((service) => [service.name, service.builtWith ?? '…']);
137
+ const found = count === 0
138
+ ? []
139
+ : [
140
+ `${color.heading(`Found ${String(count)} ${count === 1 ? 'service' : 'services'}`)}\n\n` +
141
+ table(['SERVICE', 'BUILT WITH'], rows),
142
+ ];
143
+ const leftOut = new Set([...variables.values()].flatMap((service) => Object.entries(service.skippedPlatformVariables).map(([key, file]) => `${key} in ${file} is left out, because Grayspar sets it`)));
144
+ const warnings = [
145
+ ...leftOut,
146
+ ...(isWholeProject
147
+ ? uploaded.leftRunning.map((name) => `${name} is no longer in this folder, and keeps running`)
148
+ : []),
149
+ ];
150
+ const blocks = [...found, ...(warnings.length > 0 ? [warnings.map(warning).join('\n')] : [])];
151
+ if (blocks.length > 0)
152
+ process.stdout.write(`${blocks.join('\n\n')}\n\n`);
153
+ }
154
+ async function watchDeployment(api, project, deploymentId, format) {
155
+ const follow = await commandFor(project, 'grayspar status');
156
+ const stopWatching = () => {
157
+ process.stderr.write(`\nThe deploy goes on without this terminal. To follow it, run\n${follow}\n`);
158
+ process.exit(CTRL_C_EXIT_CODE);
159
+ };
160
+ process.once('SIGINT', stopWatching);
161
+ try {
162
+ return await awaitDeployment(api, project, deploymentId, { format, label: 'Deploying…' });
163
+ }
164
+ finally {
165
+ process.off('SIGINT', stopWatching);
166
+ }
167
+ }
168
+ async function printOutcome(api, project, outcome) {
169
+ if (outcome.status === 'live') {
170
+ const live = await dataOrThrow(api.GET('/api/projects/{projectId}', { params: { path: { projectId: project.id } } }));
171
+ process.stdout.write(`${receipt(`Deployed ${live.name}`)}\n\n${servicesTable(live)}\n`);
172
+ return;
173
+ }
174
+ if (outcome.status === 'cancelled') {
175
+ await printReplacedDeploy(project);
176
+ return;
177
+ }
178
+ await printDeployFailure(api, project, outcome);
179
+ }
180
+ export const deployCommand = new Command('deploy')
181
+ .argument('[folder]', 'deploy only the files under this folder of the project', '.')
182
+ .description('build a project folder and put it online')
183
+ .option('-y, --yes', 'take the plan as shown without asking')
184
+ .option('--name <name>', 'create a new project with this name')
185
+ .option('--no-env', 'deploy without reading any .env file')
186
+ .option('--env-file <path>', 'read variables from a specific file')
187
+ .addOption(new Option('--env-conflict <winner>', 'which value wins where your .env differs from a saved one').choices(['saved', 'file']))
188
+ .option('-s, --service <name>', 'deploy only this service, repeatable', collectRepeatedValue)
189
+ .option('--path <name=path>', 'where a new service answers, repeatable', collectRepeatedValue)
190
+ .option('--no-path <name>', 'a new service that takes no requests, like a background worker', (name, paths) => collectRepeatedValue(`${name}=${NO_PATH_VALUE}`, paths))
191
+ .option('--dockerfile <name=path>', 'the Dockerfile a new service builds from', collectRepeatedValue)
192
+ .option('--volume <name=path>', 'a folder a service keeps across deploys, repeatable', collectRepeatedValue)
193
+ .action(commandAction((format, command, folder) => {
194
+ const options = command.opts();
195
+ return deploy(folder, {
196
+ format,
197
+ yes: options.yes === true,
198
+ name: options.name,
199
+ env: options.env,
200
+ envFile: options.envFile,
201
+ envConflict: options.envConflict,
202
+ flags: {
203
+ services: options.service ?? [],
204
+ paths: options.path ?? [],
205
+ dockerfiles: options.dockerfile ?? [],
206
+ volumes: options.volume ?? [],
207
+ },
208
+ });
209
+ }));
@@ -0,0 +1,47 @@
1
+ /* A variable found beside the service beats one at the repository root, and a variable Grayspar sets itself is left
2
+ out wherever it is found. */
3
+ import { readFile } from 'node:fs/promises';
4
+ import { join, resolve } from 'node:path';
5
+ import { parseEnv } from 'node:util';
6
+ import { CliError } from '../../lib/errors.js';
7
+ const ENV_FILES_IN_ORDER = ['.env', '.env.production', '.env.local'];
8
+ export function emptyFoundVariables() {
9
+ return { values: {}, sourceFiles: {}, skippedPlatformVariables: {} };
10
+ }
11
+ async function readIfPresent(folder, file) {
12
+ return readFile(resolve(folder, file), 'utf8').catch(() => null);
13
+ }
14
+ export async function variablesFor(root, serviceFolder, platformVariableNames, envFile) {
15
+ const values = {};
16
+ const sourceFiles = {};
17
+ const skippedPlatformVariables = {};
18
+ function takeVariables(raw, origin) {
19
+ for (const [name, value] of Object.entries(parseEnv(raw))) {
20
+ if (value === undefined)
21
+ continue;
22
+ if (platformVariableNames.includes(name)) {
23
+ skippedPlatformVariables[name] = origin;
24
+ continue;
25
+ }
26
+ values[name] = value;
27
+ sourceFiles[name] = origin;
28
+ }
29
+ }
30
+ const places = serviceFolder === '.' ? ['.'] : ['.', serviceFolder];
31
+ for (const place of places) {
32
+ for (const file of ENV_FILES_IN_ORDER) {
33
+ const raw = await readIfPresent(join(root, place), file);
34
+ if (raw === null)
35
+ continue;
36
+ takeVariables(raw, place === '.' ? file : `${place}/${file}`);
37
+ }
38
+ }
39
+ if (envFile !== undefined) {
40
+ const raw = await readIfPresent(process.cwd(), envFile);
41
+ if (raw === null) {
42
+ throw new CliError('no_env_file', `No environment file at ${envFile}.\nGive the path of an existing file with --env-file.`, '--env-file');
43
+ }
44
+ takeVariables(raw, envFile);
45
+ }
46
+ return { values, sourceFiles, skippedPlatformVariables };
47
+ }
@@ -0,0 +1,35 @@
1
+ /* A deploy uploads the files on disk and names the commit only to describe them, so a tree with
2
+ uncommitted or untracked files says it holds changes beyond that commit. Only the deployed folder counts. */
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ const execFileAsync = promisify(execFile);
6
+ const GIT_TIMEOUT_MS = 5000;
7
+ const NO_COMMIT_YET = '(initial)';
8
+ const DETACHED = '(detached)';
9
+ async function runGit(folder, args) {
10
+ const { stdout } = await execFileAsync('git', args, { cwd: folder, timeout: GIT_TIMEOUT_MS });
11
+ return stdout.trim();
12
+ }
13
+ export async function gitStateOf(folder) {
14
+ try {
15
+ const [status, message] = await Promise.all([
16
+ runGit(folder, ['status', '--porcelain=v2', '--branch', '--', '.']),
17
+ runGit(folder, ['log', '-1', '--format=%s']),
18
+ ]);
19
+ const lines = status.split('\n');
20
+ const header = (name) => lines.find((line) => line.startsWith(`# ${name} `))?.slice(`# ${name} `.length);
21
+ const commit = header('branch.oid');
22
+ const branch = header('branch.head');
23
+ if (commit === undefined || commit === NO_COMMIT_YET)
24
+ return null;
25
+ return {
26
+ branch: branch === undefined || branch === DETACHED ? null : branch,
27
+ commit,
28
+ message,
29
+ dirty: lines.some((line) => line !== '' && !line.startsWith('#')),
30
+ };
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
@@ -0,0 +1,13 @@
1
+ /* A project's name is its address too, so it holds only what an address can carry. The API decides; this only turns a
2
+ folder name or a typed answer into the nearest name it would accept, to offer as the answer. */
3
+ const NAME_LIMIT = 58;
4
+ export function projectNameFrom(text) {
5
+ const name = text
6
+ .toLowerCase()
7
+ .normalize('NFKD')
8
+ .replace(/\p{M}+/gu, '')
9
+ .replace(/[^a-z0-9]+/g, '-')
10
+ .slice(0, NAME_LIMIT)
11
+ .replace(/^-+|-+$/g, '');
12
+ return name === '' ? null : name;
13
+ }
@@ -0,0 +1,127 @@
1
+ import { dataOrThrow } from '../../lib/client.js';
2
+ import { CliError } from '../../lib/errors.js';
3
+ import { askChoice, askText, askYesNo, isInteractive } from '../../lib/prompt.js';
4
+ import { failure } from '../../lib/ui.js';
5
+ import { servicesToSend } from './deploy-flags.js';
6
+ const PATH_POINTER_PATTERN = /^#\/services\/(\d+)\/pathPrefix$/;
7
+ function askedIn(refusal, sent) {
8
+ if (refusal === undefined)
9
+ return null;
10
+ if (refusal.questions !== undefined)
11
+ return { asked: refusal.questions, detail: refusal.detail };
12
+ const paths = (refusal.errors ?? []).flatMap(({ detail, pointer }) => {
13
+ const index = PATH_POINTER_PATTERN.exec(pointer ?? '')?.[1];
14
+ const service = index === undefined ? undefined : sent[Number(index)]?.name;
15
+ return service === undefined ? [] : [{ service, suggestion: null, problem: detail }];
16
+ });
17
+ return paths.length === 0
18
+ ? null
19
+ : { asked: { address: [], paths, dockerfiles: [] }, detail: refusal.detail };
20
+ }
21
+ async function sendDeploy(api, destination, body) {
22
+ if (destination.kind === 'project') {
23
+ const answer = await api.POST('/api/projects/{projectId}/deployments', {
24
+ params: { path: { projectId: destination.project.id } },
25
+ body,
26
+ });
27
+ const asked = askedIn(answer.error, body.services ?? []);
28
+ if (asked !== null)
29
+ return asked;
30
+ const { deployment } = await dataOrThrow(Promise.resolve(answer));
31
+ return { accepted: { project: destination.project, deployment } };
32
+ }
33
+ const answer = await api.POST('/api/teams/{teamId}/projects', {
34
+ params: { path: { teamId: destination.team.id } },
35
+ body: { ...body, name: destination.name },
36
+ });
37
+ const asked = askedIn(answer.error, body.services ?? []);
38
+ if (asked !== null)
39
+ return asked;
40
+ return { accepted: await dataOrThrow(Promise.resolve(answer)) };
41
+ }
42
+ function dockerfileRefusal({ detail }, { dockerfile, service }) {
43
+ return new CliError('needs_an_answer', `${detail}\nAdd --dockerfile ${service}=${dockerfile} to build it, or --yes to leave it out.`, '--dockerfile');
44
+ }
45
+ function refusalFor({ asked, detail }, suggestionsTaken) {
46
+ const action = asked.address.length > 0
47
+ ? 'Choose it with --path <name>=/.'
48
+ : suggestionsTaken
49
+ ? 'Give the path with --path <name>=<path>, or --no-path <name> if it takes no requests.'
50
+ : 'Give each path with --path <name>=<path>, or --no-path <name> if it takes no requests, or take the suggested paths with --yes.';
51
+ return new CliError('needs_a_path', `${detail}\n${action}`, '--path');
52
+ }
53
+ function suggestionsFor(askedQuestions) {
54
+ const taken = new Map();
55
+ for (const { service, suggestion, problem } of askedQuestions.asked.paths) {
56
+ if (suggestion === null || problem !== null)
57
+ throw refusalFor(askedQuestions, true);
58
+ taken.set(service, suggestion);
59
+ }
60
+ if (askedQuestions.asked.address.length > 0)
61
+ throw refusalFor(askedQuestions, true);
62
+ return taken;
63
+ }
64
+ async function askFirstQuestion(asked, address) {
65
+ if (asked.address.length > 0) {
66
+ const root = await askChoice(`Select the service that opens at ${address}`, `Opens at ${address}`, asked.address.map((name) => ({ value: name, label: name })), '--path');
67
+ return [root, '/'];
68
+ }
69
+ const [question] = asked.paths;
70
+ if (question === undefined)
71
+ throw new Error('API asked a question without naming one');
72
+ if (question.problem !== null)
73
+ process.stdout.write(`${failure(question.problem)}\n`);
74
+ return [
75
+ question.service,
76
+ await askText(`Path for ${question.service}`, question.suggestion, '--path'),
77
+ ];
78
+ }
79
+ export async function submitDeploy(api, destination, options, serviceFlags, foundNames, request) {
80
+ const canAsk = isInteractive() && !options.yes;
81
+ const declinedDockerfiles = [];
82
+ let hasAsked = false;
83
+ for (;;) {
84
+ const sent = await sendDeploy(api, destination, {
85
+ ...request,
86
+ services: servicesToSend(foundNames, serviceFlags),
87
+ ...(declinedDockerfiles.length === 0 ? {} : { declinedDockerfiles }),
88
+ });
89
+ if ('accepted' in sent) {
90
+ if (hasAsked)
91
+ process.stdout.write('\n');
92
+ return sent.accepted;
93
+ }
94
+ const [dockerfile] = sent.asked.dockerfiles;
95
+ if (dockerfile !== undefined) {
96
+ if (options.yes) {
97
+ declinedDockerfiles.push(...sent.asked.dockerfiles.map((question) => question.dockerfile));
98
+ continue;
99
+ }
100
+ if (!canAsk)
101
+ throw dockerfileRefusal(sent, dockerfile);
102
+ const builds = await askYesNo(`Build ${dockerfile.dockerfile} from the project's root as the service ${dockerfile.service}?`, false, '--dockerfile');
103
+ hasAsked = true;
104
+ if (!builds) {
105
+ declinedDockerfiles.push(dockerfile.dockerfile);
106
+ continue;
107
+ }
108
+ serviceFlags.dockerfiles.set(dockerfile.service, dockerfile.dockerfile);
109
+ if (serviceFlags.onlyServices.length > 0)
110
+ serviceFlags.onlyServices.push(dockerfile.service);
111
+ continue;
112
+ }
113
+ if (!options.yes && !canAsk)
114
+ throw refusalFor(sent, false);
115
+ const given = options.yes
116
+ ? suggestionsFor(sent)
117
+ : new Map([await askFirstQuestion(sent.asked, addressOf(destination))]);
118
+ hasAsked ||= !options.yes;
119
+ for (const [name, path] of given)
120
+ serviceFlags.paths.set(name, path);
121
+ }
122
+ }
123
+ function addressOf(destination) {
124
+ return destination.kind === 'project'
125
+ ? new URL(destination.project.url).host
126
+ : "the project's address";
127
+ }
@@ -0,0 +1,140 @@
1
+ /* The saved variables are the source of truth: a deploy adds what the project lacks once its names have been shown,
2
+ asks before a local value replaces a saved one, and keeps a value that points at this machine on it. */
3
+ import { createHmac } from 'node:crypto';
4
+ import { stat } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { dataOrThrow } from '../../lib/client.js';
7
+ import { CliError } from '../../lib/errors.js';
8
+ import { askChoice, askYesNo, isInteractive } from '../../lib/prompt.js';
9
+ import { color, table, warning } from '../../lib/ui.js';
10
+ const LOCAL_HOST_PATTERN = /(?:^|[^\w.-])(?:localhost|127\.0\.0\.1)(?![\w.-])/;
11
+ export function pointsAtThisMachine(value) {
12
+ return LOCAL_HOST_PATTERN.test(value);
13
+ }
14
+ export function comparisonOf(saved, local) {
15
+ return Object.fromEntries(Object.entries(local).map(([service, values]) => [
16
+ service,
17
+ Object.fromEntries(Object.entries(values).map(([key, value]) => {
18
+ const entry = saved[service]?.find((variable) => variable.name === key);
19
+ if (entry === undefined)
20
+ return [key, { state: 'new', savedAt: null }];
21
+ const hash = createHmac('sha256', Buffer.from(entry.salt, 'base64url'))
22
+ .update(value)
23
+ .digest('hex');
24
+ return [
25
+ key,
26
+ { state: hash === entry.hash ? 'same' : 'different', savedAt: entry.updatedAt },
27
+ ];
28
+ })),
29
+ ]));
30
+ }
31
+ export function newVariablesOf(services, variables, comparison) {
32
+ return services.flatMap((service, index) => {
33
+ const found = variables[index];
34
+ if (found === undefined)
35
+ return [];
36
+ return Object.entries(found.values)
37
+ .filter(([key]) => (comparison[service]?.[key]?.state ?? 'new') === 'new')
38
+ .map(([key, value]) => ({
39
+ service,
40
+ key,
41
+ file: found.sourceFiles[key] ?? '.env',
42
+ leftOut: pointsAtThisMachine(value),
43
+ }));
44
+ });
45
+ }
46
+ export function sendableVariables(services, variables, comparison, fileWins) {
47
+ return Object.fromEntries(services.map((service, index) => {
48
+ const values = variables[index]?.values ?? {};
49
+ const kept = Object.entries(values).filter(([key]) => {
50
+ const state = comparison[service]?.[key]?.state ?? 'new';
51
+ if (state === 'new')
52
+ return true;
53
+ if (state === 'same')
54
+ return false;
55
+ return fileWins({ service, key });
56
+ });
57
+ return [service, Object.fromEntries(kept)];
58
+ }));
59
+ }
60
+ function timeAgo(at) {
61
+ const minutes = Math.max(0, Math.floor((Date.now() - at.getTime()) / 60_000));
62
+ if (minutes < 1)
63
+ return 'just now';
64
+ if (minutes < 60)
65
+ return `${minutes}m ago`;
66
+ if (minutes < 60 * 24)
67
+ return `${Math.floor(minutes / 60)}h ago`;
68
+ return `${Math.floor(minutes / (60 * 24))}d ago`;
69
+ }
70
+ async function editedAt(root, file) {
71
+ if (file === undefined)
72
+ return null;
73
+ return stat(join(root, file)).then((found) => found.mtime, () => null);
74
+ }
75
+ function leftOutWarning({ service, key, file }) {
76
+ return warning(`${key} in ${file} points at localhost, so it was left out. Set it with grayspar env set ${key}=… --service ${service}`);
77
+ }
78
+ async function confirmNewVariables(sendable, fresh, { projectName, format, yes }) {
79
+ const leftOut = fresh.filter((variable) => variable.leftOut);
80
+ const offered = fresh.filter((variable) => !variable.leftOut);
81
+ const dropped = (names) => {
82
+ for (const { service, key } of names)
83
+ delete sendable[service]?.[key];
84
+ };
85
+ dropped(leftOut);
86
+ const warnings = leftOut.map(leftOutWarning);
87
+ if (offered.length > 0 && !yes) {
88
+ const one = offered.length === 1;
89
+ const many = one ? 'variable' : 'variables';
90
+ if (format !== 'text' || !isInteractive()) {
91
+ const them = one ? 'it' : 'them';
92
+ throw new CliError('needs_an_answer', `${offered.length} ${many} in .env files ${one ? 'is' : 'are'} new to ${projectName}, and there is no terminal to ask whether to send ${them}.\nSend ${them} with --yes, or deploy without .env files with --no-env.`, '--yes');
93
+ }
94
+ process.stdout.write(`${color.heading(`Found ${offered.length} new ${many}`)}\n\n` +
95
+ `${table(['SERVICE', 'VARIABLE', 'FILE'], offered.map(({ service, key, file }) => [service, key, file]))}\n` +
96
+ (warnings.length === 0 ? '' : `\n${warnings.join('\n')}\n`) +
97
+ '\n');
98
+ const send = await askYesNo(`Send ${offered.length} ${many} to ${projectName}?`, true, '--yes');
99
+ process.stdout.write('\n');
100
+ if (!send)
101
+ dropped(offered);
102
+ }
103
+ else if (warnings.length > 0 && format === 'text') {
104
+ process.stdout.write(`${warnings.join('\n')}\n\n`);
105
+ }
106
+ return { variables: sendable, leftOut };
107
+ }
108
+ export async function variablesToSend(api, project, root, services, variables, envConflict, asking) {
109
+ const local = Object.fromEntries(services.map((service, index) => [service, variables[index]?.values ?? {}]));
110
+ const saved = project === null
111
+ ? {}
112
+ : await dataOrThrow(api.GET('/api/projects/{projectId}/env', { params: { path: { projectId: project.id } } }));
113
+ const comparison = comparisonOf(saved, local);
114
+ const fresh = newVariablesOf(services, variables, comparison);
115
+ const conflicts = services.flatMap((service) => Object.entries(comparison[service] ?? {})
116
+ .filter(([, entry]) => entry.state === 'different')
117
+ .map(([key]) => ({ service, key })));
118
+ if (conflicts.length === 0 || envConflict !== undefined) {
119
+ return confirmNewVariables(sendableVariables(services, variables, comparison, () => envConflict === 'file'), fresh, asking);
120
+ }
121
+ const names = conflicts.map(({ service, key }) => `${key} on ${service}`).join(', ');
122
+ if (!isInteractive()) {
123
+ throw new CliError('env_differs', `${names} ${conflicts.length === 1 ? 'differs from its saved value' : 'differ from their saved values'}. Pass --env-conflict saved or --env-conflict file.`, '--env-conflict');
124
+ }
125
+ const fileWinsByConflict = new Map();
126
+ for (const { service, key } of conflicts) {
127
+ const saved = comparison[service]?.[key]?.savedAt;
128
+ const index = services.indexOf(service);
129
+ const file = variables[index]?.sourceFiles[key];
130
+ const edited = await editedAt(root, file);
131
+ const savedAndEditedNote = `Saved ${saved ? timeAgo(new Date(saved)) : 'earlier'}${edited === null ? '' : `, ${file ?? '.env'} edited ${timeAgo(edited)}`}`;
132
+ process.stdout.write(`\n${color.heading(key)} on ${service} differs from the saved value.\n${savedAndEditedNote}.\n`);
133
+ const pick = await askChoice(`Select the value of ${key} for ${service}`, `${key} for ${service}`, [
134
+ { value: 'saved', label: 'Keep the saved value' },
135
+ { value: 'file', label: `Use the one in ${file ?? '.env'}` },
136
+ ], '--env-conflict');
137
+ fileWinsByConflict.set(`${service} ${key}`, pick === 'file');
138
+ }
139
+ return confirmNewVariables(sendableVariables(services, variables, comparison, ({ service, key }) => fileWinsByConflict.get(`${service} ${key}`) === true), fresh, asking);
140
+ }