@wacht/bench 0.1.0

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.
@@ -0,0 +1,205 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { stdin as input, stdout as output } from 'node:process';
3
+ import { clearBenchContext, readBenchContext, writeBenchContext } from './context-store.js';
4
+ import { createDeployment, createProject, getProjects, } from './machine-api.js';
5
+ import { canPrompt, promptChoice, promptList, promptOptionalText, promptText } from './prompts.js';
6
+ import { command, field, log, printBannerFor, printJson, section, success } from './ui.js';
7
+ const IDENTITY_AUTH_METHODS = ['email', 'phone', 'username'];
8
+ const SOCIAL_AUTH_METHODS = [
9
+ 'google_oauth',
10
+ 'apple_oauth',
11
+ 'facebook_oauth',
12
+ 'github_oauth',
13
+ 'discord_oauth',
14
+ 'linkedin_oauth',
15
+ 'gitlab_oauth',
16
+ 'x_oauth',
17
+ ];
18
+ const DEPLOYMENT_AUTH_METHODS = [...IDENTITY_AUTH_METHODS, ...SOCIAL_AUTH_METHODS];
19
+ const PRODUCTION_AUTH_METHODS = IDENTITY_AUTH_METHODS;
20
+ function choicesFrom(projects) {
21
+ return projects.flatMap((project) => project.deployment_items.map((deployment) => ({ project, deployment })));
22
+ }
23
+ function contextFrom(choice) {
24
+ return {
25
+ project_id: choice.project.id,
26
+ project_name: choice.project.name,
27
+ deployment_id: choice.deployment.id,
28
+ deployment_mode: choice.deployment.mode,
29
+ deployment_backend_host: choice.deployment.backend_host,
30
+ deployment_frontend_host: choice.deployment.frontend_host,
31
+ updated_at: Date.now(),
32
+ };
33
+ }
34
+ function findChoice(projects, options) {
35
+ const choices = choicesFrom(projects);
36
+ if (options.deployment) {
37
+ return choices.find((choice) => choice.deployment.id === options.deployment) ?? null;
38
+ }
39
+ if (options.project && options.mode) {
40
+ return choices.find((choice) => choice.project.id === options.project && choice.deployment.mode === options.mode) ?? null;
41
+ }
42
+ if (options.project) {
43
+ const projectChoices = choices.filter((choice) => choice.project.id === options.project);
44
+ return projectChoices.length === 1 ? projectChoices[0] : null;
45
+ }
46
+ return choices.length === 1 ? choices[0] : null;
47
+ }
48
+ async function promptForChoice(choices) {
49
+ const rl = createInterface({ input, output });
50
+ try {
51
+ for (const [index, choice] of choices.entries()) {
52
+ const label = `${choice.project.name} / ${choice.deployment.mode}`;
53
+ console.log(`${index + 1}. ${label} (${choice.deployment.id})`);
54
+ }
55
+ console.log('');
56
+ const answer = await rl.question('Select active deployment: ');
57
+ const index = Number.parseInt(answer, 10) - 1;
58
+ if (!Number.isInteger(index) || index < 0 || index >= choices.length) {
59
+ throw new Error('Invalid deployment selection.');
60
+ }
61
+ return choices[index];
62
+ }
63
+ finally {
64
+ rl.close();
65
+ }
66
+ }
67
+ function defaultMethods(methods) {
68
+ return methods && methods.length ? methods : ['email'];
69
+ }
70
+ function methodPrompt(allowedMethods) {
71
+ return `Auth methods, comma separated [email]. Allowed: ${allowedMethods.join(', ')}: `;
72
+ }
73
+ function validateMethods(methods, allowedMethods) {
74
+ const normalized = methods.map((method) => method.trim()).filter(Boolean);
75
+ const invalid = normalized.filter((method) => !allowedMethods.includes(method));
76
+ if (invalid.length) {
77
+ throw new Error(`Invalid auth method(s): ${invalid.join(', ')}. Allowed: ${allowedMethods.join(', ')}.`);
78
+ }
79
+ if (!normalized.length) {
80
+ throw new Error(`At least one auth method is required. Allowed: ${allowedMethods.join(', ')}.`);
81
+ }
82
+ return Array.from(new Set(normalized));
83
+ }
84
+ function printActiveContext(ctx, context) {
85
+ log(ctx, field('Project', `${context.project_name} (${context.project_id})`));
86
+ log(ctx, field('Deployment', `${context.deployment_mode} (${context.deployment_id})`));
87
+ if (context.deployment_backend_host) {
88
+ log(ctx, field('Backend', context.deployment_backend_host));
89
+ }
90
+ if (context.deployment_frontend_host) {
91
+ log(ctx, field('Frontend', context.deployment_frontend_host));
92
+ }
93
+ }
94
+ export async function currentDeployment(ctx) {
95
+ const context = await readBenchContext();
96
+ if (ctx.json) {
97
+ printJson({ ok: true, active: context });
98
+ return;
99
+ }
100
+ printBannerFor(ctx);
101
+ log(ctx, section('Active Deployment'));
102
+ if (!context) {
103
+ log(ctx, 'No active deployment selected.');
104
+ log(ctx, `Run ${command('wacht deployments select')} to choose one.`);
105
+ return;
106
+ }
107
+ printActiveContext(ctx, context);
108
+ }
109
+ export async function selectDeployment(ctx, options = {}) {
110
+ const projects = await getProjects();
111
+ const choices = choicesFrom(projects);
112
+ if (!choices.length) {
113
+ if (options.optional)
114
+ return null;
115
+ throw new Error('No deployments found. Create a project or deployment first.');
116
+ }
117
+ let choice = findChoice(projects, options);
118
+ if (!choice) {
119
+ if (!canPrompt(ctx)) {
120
+ if (options.optional)
121
+ return null;
122
+ throw new Error('Pass --deployment <id>, or --project <id> --mode <staging|production>.');
123
+ }
124
+ printBannerFor(ctx);
125
+ log(ctx, section('Select Active Deployment'));
126
+ choice = await promptForChoice(choices);
127
+ }
128
+ const next = contextFrom(choice);
129
+ await writeBenchContext(next);
130
+ if (ctx.json) {
131
+ printJson({ ok: true, active: next });
132
+ return next;
133
+ }
134
+ log(ctx, '');
135
+ log(ctx, success('Active deployment selected.'));
136
+ printActiveContext(ctx, next);
137
+ return next;
138
+ }
139
+ export async function clearDeployment(ctx) {
140
+ await clearBenchContext();
141
+ if (ctx.json) {
142
+ printJson({ ok: true, active: null });
143
+ return;
144
+ }
145
+ log(ctx, success('Active deployment cleared.'));
146
+ }
147
+ export async function createProjectCommand(ctx, options) {
148
+ const name = await promptText(ctx, options.name, 'Project name: ', 'Pass --name <name> to create a project.');
149
+ const methods = validateMethods(await promptList(ctx, options.method, methodPrompt(DEPLOYMENT_AUTH_METHODS), defaultMethods(options.method)), DEPLOYMENT_AUTH_METHODS);
150
+ const project = await createProject(name, methods);
151
+ const firstDeployment = project.deployment_items[0];
152
+ if (firstDeployment && options.select !== false) {
153
+ await writeBenchContext(contextFrom({ project, deployment: firstDeployment }));
154
+ }
155
+ if (ctx.json) {
156
+ printJson({
157
+ ok: true,
158
+ project,
159
+ active: firstDeployment && options.select !== false ? contextFrom({ project, deployment: firstDeployment }) : null,
160
+ });
161
+ return;
162
+ }
163
+ printBannerFor(ctx);
164
+ log(ctx, section('Project Created'));
165
+ log(ctx, field('Project', `${project.name} (${project.id})`));
166
+ if (firstDeployment && options.select !== false) {
167
+ log(ctx, field('Active deployment', `${firstDeployment.mode} (${firstDeployment.id})`));
168
+ }
169
+ }
170
+ export async function createDeploymentCommand(ctx, options) {
171
+ const current = await readBenchContext();
172
+ const projectId = await promptText(ctx, options.project ?? current?.project_id, 'Project ID: ', 'Pass --project <id>, or select an active deployment first.');
173
+ const mode = await promptChoice(ctx, options.mode, ['staging', 'production'], 'Deployment mode: ', 'Pass deployment mode: staging or production.');
174
+ const domain = mode === 'production'
175
+ ? await promptText(ctx, options.domain, 'Production custom domain: ', 'Pass --domain <domain> when creating a production deployment.')
176
+ : await promptOptionalText(ctx, options.domain, 'Custom domain, optional: ');
177
+ const allowedMethods = mode === 'production' ? PRODUCTION_AUTH_METHODS : DEPLOYMENT_AUTH_METHODS;
178
+ const methods = validateMethods(await promptList(ctx, options.method, methodPrompt(allowedMethods), defaultMethods(options.method)), allowedMethods);
179
+ const deployment = await createDeployment(projectId, mode, methods, domain);
180
+ const project = (await getProjects()).find((item) => item.id === projectId);
181
+ if (!project) {
182
+ throw new Error('Deployment was created, but the project was not returned by /projects.');
183
+ }
184
+ const selectedProject = {
185
+ ...project,
186
+ deployment_items: project.deployment_items.some((item) => item.id === deployment.id)
187
+ ? project.deployment_items
188
+ : [...project.deployment_items, deployment],
189
+ };
190
+ const active = contextFrom({ project: selectedProject, deployment });
191
+ if (options.select !== false) {
192
+ await writeBenchContext(active);
193
+ }
194
+ if (ctx.json) {
195
+ printJson({ ok: true, deployment, active: options.select !== false ? active : null });
196
+ return;
197
+ }
198
+ printBannerFor(ctx);
199
+ log(ctx, section('Deployment Created'));
200
+ log(ctx, field('Project', `${project.name} (${project.id})`));
201
+ log(ctx, field('Deployment', `${deployment.mode} (${deployment.id})`));
202
+ if (options.select !== false) {
203
+ log(ctx, field('Active', 'yes'));
204
+ }
205
+ }
package/dist/guards.js ADDED
@@ -0,0 +1,23 @@
1
+ export function isJsonObject(value) {
2
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
3
+ }
4
+ export function isStoredAuth(value) {
5
+ return isJsonObject(value)
6
+ && typeof value.client_id === 'string'
7
+ && typeof value.access_token === 'string'
8
+ && typeof value.refresh_token === 'string'
9
+ && typeof value.token_type === 'string'
10
+ && typeof value.expires_at === 'number'
11
+ && typeof value.redirect_uri === 'string'
12
+ && typeof value.machine_api_url === 'string'
13
+ && typeof value.oauth_issuer === 'string'
14
+ && (value.scope === undefined || typeof value.scope === 'string');
15
+ }
16
+ export function isOAuthTokenResponse(value) {
17
+ return isJsonObject(value)
18
+ && typeof value.access_token === 'string'
19
+ && typeof value.token_type === 'string'
20
+ && typeof value.expires_in === 'number'
21
+ && typeof value.refresh_token === 'string'
22
+ && typeof value.scope === 'string';
23
+ }