@hs-x/cli 0.1.2 → 0.2.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.
- package/dist/account-store.d.ts +45 -22
- package/dist/account-store.d.ts.map +1 -1
- package/dist/account-store.js +73 -16
- package/dist/account-store.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +170 -13
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/kit.d.ts +1 -1
- package/dist/cli/kit.d.ts.map +1 -1
- package/dist/cli/kit.js.map +1 -1
- package/dist/commands/account.d.ts +6 -6
- package/dist/commands/account.d.ts.map +1 -1
- package/dist/commands/account.js +25 -23
- package/dist/commands/account.js.map +1 -1
- package/dist/commands/api.d.ts +1 -1
- package/dist/commands/completion.d.ts.map +1 -1
- package/dist/commands/completion.js +6 -4
- package/dist/commands/completion.js.map +1 -1
- package/dist/commands/connect.d.ts +16 -0
- package/dist/commands/connect.d.ts.map +1 -1
- package/dist/commands/connect.js +143 -17
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/control-plane-read.d.ts +3 -3
- package/dist/commands/control-plane-read.d.ts.map +1 -1
- package/dist/commands/control-plane-read.js +21 -33
- package/dist/commands/control-plane-read.js.map +1 -1
- package/dist/commands/deploy-promote.d.ts +1 -1
- package/dist/commands/deploy-promote.d.ts.map +1 -1
- package/dist/commands/deploy-promote.js +3 -3
- package/dist/commands/deploy-promote.js.map +1 -1
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +39 -2
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +11 -0
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/doctor.d.ts +1 -1
- package/dist/commands/doctor.js +2 -2
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/flags.d.ts +1 -1
- package/dist/commands/flags.d.ts.map +1 -1
- package/dist/commands/flags.js +59 -35
- package/dist/commands/flags.js.map +1 -1
- package/dist/commands/help-command.d.ts +14 -0
- package/dist/commands/help-command.d.ts.map +1 -1
- package/dist/commands/help-command.js +274 -20
- package/dist/commands/help-command.js.map +1 -1
- package/dist/commands/init.d.ts +11 -8
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +435 -246
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/link.d.ts +1 -26
- package/dist/commands/link.d.ts.map +1 -1
- package/dist/commands/link.js +11 -236
- package/dist/commands/link.js.map +1 -1
- package/dist/commands/login.d.ts +4 -12
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +196 -390
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/migrate.d.ts +2 -7
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +84 -201
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/rollback.d.ts +1 -20
- package/dist/commands/rollback.d.ts.map +1 -1
- package/dist/commands/rollback.js +6 -275
- package/dist/commands/rollback.js.map +1 -1
- package/dist/commands/status.d.ts +1 -1
- package/dist/commands/status.js +1 -1
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/unlink.d.ts +1 -21
- package/dist/commands/unlink.d.ts.map +1 -1
- package/dist/commands/unlink.js +12 -94
- package/dist/commands/unlink.js.map +1 -1
- package/dist/commands/update.js +7 -7
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/validate.d.ts +2 -2
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +1 -1
- package/dist/commands/validate.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +7 -0
- package/dist/config.js.map +1 -1
- package/dist/constants.d.ts +1 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +5 -1
- package/dist/constants.js.map +1 -1
- package/dist/control-plane-fetch.d.ts.map +1 -1
- package/dist/control-plane-fetch.js +2 -2
- package/dist/control-plane-fetch.js.map +1 -1
- package/dist/control-plane-loader.d.ts +1 -1
- package/dist/control-plane-loader.js +1 -1
- package/dist/errors-registry.js +1 -1
- package/dist/errors-registry.js.map +1 -1
- package/dist/help.d.ts.map +1 -1
- package/dist/help.js +3 -2
- package/dist/help.js.map +1 -1
- package/dist/init/templates.d.ts +11 -3
- package/dist/init/templates.d.ts.map +1 -1
- package/dist/init/templates.js +96 -25
- package/dist/init/templates.js.map +1 -1
- package/dist/project-binding.d.ts +17 -0
- package/dist/project-binding.d.ts.map +1 -0
- package/dist/project-binding.js +30 -0
- package/dist/project-binding.js.map +1 -0
- package/dist/prompt.d.ts +21 -0
- package/dist/prompt.d.ts.map +1 -1
- package/dist/prompt.js +186 -50
- package/dist/prompt.js.map +1 -1
- package/dist/reporter/human.d.ts +5 -0
- package/dist/reporter/human.d.ts.map +1 -1
- package/dist/reporter/human.js +48 -18
- package/dist/reporter/human.js.map +1 -1
- package/dist/reporter/json.d.ts +6 -0
- package/dist/reporter/json.d.ts.map +1 -1
- package/dist/reporter/json.js +16 -0
- package/dist/reporter/json.js.map +1 -1
- package/dist/reporter/style.d.ts +1 -1
- package/dist/reporter/style.d.ts.map +1 -1
- package/dist/reporter/style.js +10 -3
- package/dist/reporter/style.js.map +1 -1
- package/dist/reporter/types.d.ts +9 -0
- package/dist/reporter/types.d.ts.map +1 -1
- package/dist/reporter/types.js.map +1 -1
- package/dist/services/account-store.d.ts +9 -4
- package/dist/services/account-store.d.ts.map +1 -1
- package/dist/services/account-store.js +46 -22
- package/dist/services/account-store.js.map +1 -1
- package/dist/services/control-plane.js +3 -2
- package/dist/services/control-plane.js.map +1 -1
- package/dist/services/live.d.ts +1 -1
- package/dist/services/reporter.d.ts +3 -0
- package/dist/services/reporter.d.ts.map +1 -1
- package/dist/services/reporter.js +2 -0
- package/dist/services/reporter.js.map +1 -1
- package/package.json +11 -8
package/dist/commands/login.js
CHANGED
|
@@ -1,421 +1,227 @@
|
|
|
1
|
-
import { defaultStorePath, setUserSession, } from '../account-store.js';
|
|
2
1
|
import * as Command from '@effect/cli/Command';
|
|
3
2
|
import * as Options from '@effect/cli/Options';
|
|
4
|
-
import { HttpClientRequest
|
|
5
|
-
import { Effect, Option } from 'effect';
|
|
6
|
-
import { exitWith } from '../cli-error.js';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { executeCliHttp } from '../effect-http.js';
|
|
10
|
-
import { emitHelp } from '../help.js';
|
|
3
|
+
import { HttpClientRequest } from '@hs-x/types';
|
|
4
|
+
import { Clock, Effect, Option } from 'effect';
|
|
5
|
+
import { cliError, exitWith } from '../cli-error.js';
|
|
6
|
+
import { controlPlaneUrlOption, jsonOption, runHandler } from '../cli/kit.js';
|
|
7
|
+
import { isCustomControlPlane, resolveControlPlaneUrl } from '../config.js';
|
|
11
8
|
import { openInBrowser } from '../hubspot-auth.js';
|
|
12
|
-
import {
|
|
13
|
-
import { promptText } from '../prompt.js';
|
|
14
|
-
import { createReporter } from '../reporter/index.js';
|
|
9
|
+
import { isInteractive, promptSelect, promptText } from '../prompt.js';
|
|
15
10
|
import { AccountStore } from '../services/account-store.js';
|
|
16
11
|
import { CliHttp } from '../services/http.js';
|
|
17
12
|
import { Reporter } from '../services/reporter.js';
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
// Local heuristic only — the control plane enforces real token expiry; this
|
|
14
|
+
// gates whether the CLI bothers sending a likely-dead token.
|
|
20
15
|
const THIRTY_DAYS_MS = 30 * 24 * 3_600_000;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
// Default is the device-authorization flow; magic-link is the browserless /
|
|
30
|
-
// CI fallback kept behind --magic-link.
|
|
31
|
-
if (argv.includes('--magic-link')) {
|
|
32
|
-
return magicLinkLogin(input);
|
|
33
|
-
}
|
|
34
|
-
return deviceFlowLogin(input);
|
|
16
|
+
/** Dashboard page where users generate a CLI API token to paste. */
|
|
17
|
+
function tokenPageUrl() {
|
|
18
|
+
const base = process.env.HSX_DASHBOARD_URL ?? 'https://app.hs-x.dev';
|
|
19
|
+
return new URL('/settings/tokens', base).toString();
|
|
20
|
+
}
|
|
21
|
+
function authErr(message) {
|
|
22
|
+
return cliError('HSX_E_API_UNAUTHORIZED', message);
|
|
35
23
|
}
|
|
36
24
|
/* -------------------------------------------------------------------------- */
|
|
37
|
-
/*
|
|
25
|
+
/* hs-x login — paste a per-account API token (HubSpot `hs`-style) */
|
|
38
26
|
/* -------------------------------------------------------------------------- */
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
deviceName,
|
|
57
|
-
cwd,
|
|
27
|
+
/**
|
|
28
|
+
* Authenticate an HS-X account by pasting an API token generated in the HS-X
|
|
29
|
+
* dashboard. The token authenticates a *user*, but is stored against one
|
|
30
|
+
* *HS-X account* (a person may have several user accounts, and each HS-X
|
|
31
|
+
* account carries its own token — like HubSpot's per-account personal access
|
|
32
|
+
* keys). The active account's token is what every control-plane call uses.
|
|
33
|
+
*/
|
|
34
|
+
function loginBody(opts) {
|
|
35
|
+
return Effect.gen(function* () {
|
|
36
|
+
const accounts = yield* AccountStore;
|
|
37
|
+
const http = yield* CliHttp;
|
|
38
|
+
const reporter = yield* Reporter;
|
|
39
|
+
const json = opts.json;
|
|
40
|
+
const interactive = !json && isInteractive();
|
|
41
|
+
const controlPlaneUrl = Option.match(opts.controlPlaneUrl, {
|
|
42
|
+
onNone: () => resolveControlPlaneUrl([]),
|
|
43
|
+
onSome: (value) => resolveControlPlaneUrl(['--control-plane-url', value]),
|
|
58
44
|
});
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
45
|
+
if (!json)
|
|
46
|
+
yield* reporter.header(isCustomControlPlane(controlPlaneUrl) ? `login → ${controlPlaneUrl}` : 'login');
|
|
47
|
+
// 1. Obtain the token: flag, env, or interactive paste after opening the
|
|
48
|
+
// dashboard token page.
|
|
49
|
+
let token = Option.getOrUndefined(opts.token)?.trim() || process.env.HSX_API_TOKEN?.trim() || undefined;
|
|
50
|
+
if (!token) {
|
|
51
|
+
if (!interactive) {
|
|
52
|
+
yield* reporter.error('HSX_E_INPUT_MISSING_VALUE', 'No API token provided.', {
|
|
53
|
+
hint: 'Generate one in the HS-X dashboard, then pass --token <token> or set HSX_API_TOKEN.',
|
|
54
|
+
});
|
|
55
|
+
yield* reporter.done(undefined, 10);
|
|
56
|
+
return yield* Effect.fail(exitWith(10, 'HSX_E_INPUT_MISSING_VALUE'));
|
|
57
|
+
}
|
|
58
|
+
const page = tokenPageUrl();
|
|
59
|
+
yield* reporter.info(`Opening ${page}`);
|
|
60
|
+
yield* reporter.info('Generate a CLI token there, then paste it here.');
|
|
61
|
+
yield* Effect.promise(() => openInBrowser(page));
|
|
62
|
+
const pasted = yield* Effect.promise(() => promptText({
|
|
63
|
+
message: 'Paste your HS-X API token',
|
|
64
|
+
validate: (v) => (v.trim().length >= 10 ? undefined : 'That token looks too short.'),
|
|
65
|
+
}));
|
|
66
|
+
if (pasted === undefined) {
|
|
67
|
+
yield* reporter.info('Cancelled.');
|
|
68
|
+
return yield* Effect.fail(exitWith(130));
|
|
69
|
+
}
|
|
70
|
+
token = pasted.trim();
|
|
65
71
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
result = await pollCliLoginPairing({
|
|
103
|
-
controlPlaneUrl,
|
|
104
|
-
pairingId: created.pairingId,
|
|
105
|
-
deviceCode: created.deviceCode,
|
|
106
|
-
intervalSeconds: created.interval,
|
|
107
|
-
expiresInSeconds: created.expiresIn,
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
catch (error) {
|
|
111
|
-
reporter.error('HSX_E_API_UNEXPECTED', 'Login polling failed.', {
|
|
112
|
-
hint: error instanceof Error ? error.message : String(error),
|
|
113
|
-
});
|
|
114
|
-
reporter.done(undefined, 1);
|
|
115
|
-
return { exitCode: 1 };
|
|
116
|
-
}
|
|
117
|
-
if (result.status === 'expired') {
|
|
118
|
-
reporter.error('HSX_E_API_UNAUTHORIZED', 'Login request expired before approval.', {
|
|
119
|
-
hint: 'Re-run `hs-x login` and approve the code in the dashboard within the time limit.',
|
|
120
|
-
});
|
|
121
|
-
reporter.done(undefined, 1);
|
|
122
|
-
return { exitCode: 1 };
|
|
123
|
-
}
|
|
124
|
-
if (result.status !== 'approved' || !result.token) {
|
|
125
|
-
reporter.error('HSX_E_API_UNEXPECTED', 'Login was not approved in time.', {
|
|
126
|
-
hint: `Open ${authorizeUrl} to approve, then re-run \`hs-x login\`.`,
|
|
72
|
+
// 2. Validate the token and learn who it belongs to.
|
|
73
|
+
const me = yield* validateToken(http, controlPlaneUrl, token).pipe(Effect.catchAll((error) => Effect.gen(function* () {
|
|
74
|
+
yield* reporter.error('HSX_E_API_UNAUTHORIZED', 'Token rejected by the control plane.', {
|
|
75
|
+
hint: error.message,
|
|
76
|
+
});
|
|
77
|
+
yield* reporter.done(undefined, 10);
|
|
78
|
+
return yield* Effect.fail(exitWith(10, 'HSX_E_API_UNAUTHORIZED'));
|
|
79
|
+
})));
|
|
80
|
+
// 3. Resolve which HS-X account to bind the token to.
|
|
81
|
+
const list = yield* listUserAccounts(http, controlPlaneUrl, token);
|
|
82
|
+
const target = yield* resolveTargetAccount({
|
|
83
|
+
flagAccount: Option.getOrUndefined(opts.account)?.trim(),
|
|
84
|
+
list,
|
|
85
|
+
interactive,
|
|
86
|
+
}).pipe(Effect.catchAll((error) => Effect.gen(function* () {
|
|
87
|
+
// A silent error is the prompt-cancel signal — propagate without re-rendering.
|
|
88
|
+
if (error.silent)
|
|
89
|
+
return yield* Effect.fail(error);
|
|
90
|
+
yield* reporter.error('HSX_E_INPUT_BAD_FIELD', error.message, {
|
|
91
|
+
hint: 'Create an HS-X account in the dashboard, or pass --account <id>.',
|
|
92
|
+
});
|
|
93
|
+
yield* reporter.done(undefined, 10);
|
|
94
|
+
return yield* Effect.fail(exitWith(10, 'HSX_E_INPUT_BAD_FIELD'));
|
|
95
|
+
})));
|
|
96
|
+
// 4. Persist the token against that account and make it active.
|
|
97
|
+
const nowMs = yield* Clock.currentTimeMillis;
|
|
98
|
+
const session = {
|
|
99
|
+
sessionToken: token,
|
|
100
|
+
expiresAt: new Date(nowMs + THIRTY_DAYS_MS).toISOString(),
|
|
101
|
+
email: me.email,
|
|
102
|
+
userId: me.id,
|
|
103
|
+
accountId: target.accountId,
|
|
104
|
+
};
|
|
105
|
+
yield* accounts.setAccountSession(target.accountId, session, {
|
|
106
|
+
...(target.displayName ? { displayName: target.displayName } : {}),
|
|
107
|
+
makeDefault: true,
|
|
127
108
|
});
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
...(result.accountId ? { accountId: result.accountId } : {}),
|
|
138
|
-
};
|
|
139
|
-
await setUserSession(session, defaultStorePath());
|
|
140
|
-
if (json) {
|
|
141
|
-
process.stdout.write(`${JSON.stringify({
|
|
142
|
-
ok: true,
|
|
143
|
-
command: 'login',
|
|
144
|
-
mode: 'device',
|
|
145
|
-
status: 'approved',
|
|
146
|
-
userId: session.userId,
|
|
147
|
-
email: session.email,
|
|
148
|
-
...(session.accountId ? { accountId: session.accountId } : {}),
|
|
149
|
-
})}\n`);
|
|
150
|
-
}
|
|
151
|
-
else {
|
|
152
|
-
const who = session.email || session.userId || 'your account';
|
|
153
|
-
reporter.block(`Session stored in ${defaultStorePath()} · \`hs-x logout\` to revoke`);
|
|
154
|
-
reporter.done(`Logged in as ${who}${session.accountId ? ` (account ${session.accountId})` : ''}`, 0);
|
|
155
|
-
}
|
|
156
|
-
return { exitCode: 0 };
|
|
157
|
-
}
|
|
158
|
-
async function pollCliLoginPairing(input) {
|
|
159
|
-
const intervalMs = Math.max(1, input.intervalSeconds) * 1000;
|
|
160
|
-
const deadline = Date.now() + Math.max(1, input.expiresInSeconds) * 1000;
|
|
161
|
-
const url = new URL(`/v1/cli/login/pairings/${encodeURIComponent(input.pairingId)}`, input.controlPlaneUrl);
|
|
162
|
-
let last = { status: 'pending' };
|
|
163
|
-
while (Date.now() < deadline) {
|
|
164
|
-
const response = await executeCliHttp(HttpClientRequest.get(url).pipe(HttpClientRequest.setHeader('authorization', `Bearer ${input.deviceCode}`)));
|
|
165
|
-
if (response.ok) {
|
|
166
|
-
last = Schema.decodeUnknownSync(schemas.CliLoginPairingReadResponse)(await response.json());
|
|
167
|
-
if (last.status === 'approved' && last.token)
|
|
168
|
-
return last;
|
|
169
|
-
if (last.status === 'expired')
|
|
170
|
-
return last;
|
|
109
|
+
if (json) {
|
|
110
|
+
yield* reporter.raw(`${JSON.stringify({
|
|
111
|
+
ok: true,
|
|
112
|
+
command: 'login',
|
|
113
|
+
status: 'approved',
|
|
114
|
+
userId: me.id,
|
|
115
|
+
email: me.email,
|
|
116
|
+
accountId: target.accountId,
|
|
117
|
+
})}\n`);
|
|
171
118
|
}
|
|
172
|
-
else
|
|
173
|
-
|
|
119
|
+
else {
|
|
120
|
+
const label = target.displayName
|
|
121
|
+
? `${target.displayName} (${target.accountId})`
|
|
122
|
+
: target.accountId;
|
|
123
|
+
yield* reporter.block('Token stored · `hs-x logout` to revoke');
|
|
124
|
+
yield* reporter.done(`Logged in as ${me.email || me.id} → ${label}`, 0);
|
|
174
125
|
}
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
return last;
|
|
178
|
-
}
|
|
179
|
-
function cliAuthorizeUrl(userCode, pairingId) {
|
|
180
|
-
const base = process.env.HSX_DASHBOARD_URL ?? 'https://app.hs-x.dev';
|
|
181
|
-
const url = new URL('/cli/authorize', base);
|
|
182
|
-
url.searchParams.set('code', userCode);
|
|
183
|
-
url.searchParams.set('pairing', pairingId);
|
|
184
|
-
return url.toString();
|
|
126
|
+
});
|
|
185
127
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
reporter.header(`magic-link → ${controlPlaneUrl}`);
|
|
197
|
-
const email = resolveFlag(argv, '--email') ??
|
|
198
|
-
process.env.HSX_LOGIN_EMAIL ??
|
|
199
|
-
(await promptText({
|
|
200
|
-
message: 'Email address',
|
|
201
|
-
validate: (value) => /.+@.+\..+/.test(value) ? undefined : 'Enter a valid email address.',
|
|
202
|
-
}));
|
|
203
|
-
if (!email || email.length === 0) {
|
|
204
|
-
reporter.error('HSX_E_INPUT_MISSING_VALUE', 'Email is required for login.', {
|
|
205
|
-
hint: 'Re-run with --email <you@example.com> or HSX_LOGIN_EMAIL=...',
|
|
206
|
-
docs_url: 'https://hs-x.dev/errors/HSX_E_INPUT_MISSING_VALUE',
|
|
128
|
+
/** GET /v1/sessions/me with the token as a bearer; returns the user or fails. */
|
|
129
|
+
function validateToken(http, controlPlaneUrl, token) {
|
|
130
|
+
return Effect.gen(function* () {
|
|
131
|
+
const response = yield* http.execute(HttpClientRequest.get(new URL('/v1/sessions/me', controlPlaneUrl)).pipe(HttpClientRequest.setHeader('authorization', `Bearer ${token}`)));
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
return yield* Effect.fail(authErr(`Validation returned ${response.status}. Generate a fresh token and retry.`));
|
|
134
|
+
}
|
|
135
|
+
const body = yield* Effect.tryPromise({
|
|
136
|
+
try: () => response.json(),
|
|
137
|
+
catch: () => authErr('Could not parse the validation response.'),
|
|
207
138
|
});
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (!start.ok) {
|
|
214
|
-
const body = await start.text();
|
|
215
|
-
reporter.error('HSX_E_API_UNEXPECTED', `Control plane refused to send a magic link (${start.status}).`, { hint: body.slice(0, 200) || 'Check your network and try again.' });
|
|
216
|
-
reporter.done(undefined, 1);
|
|
217
|
-
return { exitCode: 1 };
|
|
218
|
-
}
|
|
219
|
-
reporter.info(`Magic link sent to ${email}. Check your inbox.`);
|
|
220
|
-
// Step 2: ask the user to paste the link from their email. The email links to
|
|
221
|
-
// the dashboard callback (`/auth/callback?token=…`), so accept any URL that
|
|
222
|
-
// carries a `token` param — we only need the token, and we re-derive the
|
|
223
|
-
// control-plane consume URL ourselves below.
|
|
224
|
-
const linkUrl = await promptText({
|
|
225
|
-
message: 'Paste the magic link URL from the email',
|
|
226
|
-
validate: (value) => {
|
|
227
|
-
try {
|
|
228
|
-
return new URL(value.trim()).searchParams.get('token')
|
|
229
|
-
? undefined
|
|
230
|
-
: 'That URL has no ?token=… parameter. Paste the full link from the email.';
|
|
231
|
-
}
|
|
232
|
-
catch {
|
|
233
|
-
return 'Enter the full URL from the email (it should start with https://).';
|
|
234
|
-
}
|
|
235
|
-
},
|
|
139
|
+
const id = typeof body.user?.id === 'string' ? body.user.id : undefined;
|
|
140
|
+
const email = typeof body.user?.email === 'string' ? body.user.email : '';
|
|
141
|
+
if (!id)
|
|
142
|
+
return yield* Effect.fail(authErr('Validation response had no user id.'));
|
|
143
|
+
return { id, email };
|
|
236
144
|
});
|
|
237
|
-
if (!linkUrl) {
|
|
238
|
-
reporter.error('HSX_E_INPUT_MISSING_VALUE', 'No magic-link URL provided.', {
|
|
239
|
-
hint: 'Click the link in your email and paste the URL, or re-run `hs-x login`.',
|
|
240
|
-
});
|
|
241
|
-
reporter.done(undefined, 10);
|
|
242
|
-
return { exitCode: 10 };
|
|
243
|
-
}
|
|
244
|
-
let consumeUrl;
|
|
245
|
-
try {
|
|
246
|
-
consumeUrl = new URL(linkUrl);
|
|
247
|
-
}
|
|
248
|
-
catch {
|
|
249
|
-
reporter.error('HSX_E_INPUT_BAD_FIELD', 'Pasted value is not a valid URL.', {});
|
|
250
|
-
reporter.done(undefined, 10);
|
|
251
|
-
return { exitCode: 10 };
|
|
252
|
-
}
|
|
253
|
-
const token = consumeUrl.searchParams.get('token');
|
|
254
|
-
if (!token) {
|
|
255
|
-
reporter.error('HSX_E_INPUT_BAD_FIELD', 'Pasted URL has no `token` query param.', {});
|
|
256
|
-
reporter.done(undefined, 10);
|
|
257
|
-
return { exitCode: 10 };
|
|
258
|
-
}
|
|
259
|
-
// Step 3: consume the token ourselves. The CP responds 302 + Set-Cookie;
|
|
260
|
-
// we intercept (manual redirect) and parse the cookie value, which is the
|
|
261
|
-
// bearer the runtime auth.ts validates via `getWebSessionByToken`.
|
|
262
|
-
const consume = await executeCliHttp(HttpClientRequest.get(new URL(`/v1/auth/magic-link/consume?token=${encodeURIComponent(token)}`, controlPlaneUrl)), { redirect: 'manual' });
|
|
263
|
-
if (consume.status !== 302) {
|
|
264
|
-
const body = await consume.text();
|
|
265
|
-
reporter.error('HSX_E_API_UNEXPECTED', `Magic-link consume failed (${consume.status}).`, { hint: body.slice(0, 200) });
|
|
266
|
-
reporter.done(undefined, 1);
|
|
267
|
-
return { exitCode: 1 };
|
|
268
|
-
}
|
|
269
|
-
const setCookie = consume.headers.get('set-cookie');
|
|
270
|
-
if (!setCookie) {
|
|
271
|
-
reporter.error('HSX_E_API_UNEXPECTED', 'Magic-link consume did not set a session cookie.', {});
|
|
272
|
-
reporter.done(undefined, 1);
|
|
273
|
-
return { exitCode: 1 };
|
|
274
|
-
}
|
|
275
|
-
const sessionToken = extractCookieValue(setCookie, SESSION_COOKIE_NAME);
|
|
276
|
-
if (!sessionToken) {
|
|
277
|
-
reporter.error('HSX_E_API_UNEXPECTED', `Magic-link consume Set-Cookie did not contain ${SESSION_COOKIE_NAME}.`, { hint: 'Server may be misconfigured; try again or contact support.' });
|
|
278
|
-
reporter.done(undefined, 1);
|
|
279
|
-
return { exitCode: 1 };
|
|
280
|
-
}
|
|
281
|
-
const expiresAt = extractCookieExpires(setCookie) ?? new Date(Date.now() + THIRTY_DAYS_MS).toISOString();
|
|
282
|
-
// Step 4: confirm with /v1/sessions/me so we know who we are.
|
|
283
|
-
const me = await executeCliHttp(HttpClientRequest.get(new URL('/v1/sessions/me', controlPlaneUrl)).pipe(HttpClientRequest.setHeader('authorization', `Bearer ${sessionToken}`)));
|
|
284
|
-
if (!me.ok) {
|
|
285
|
-
reporter.error('HSX_E_API_UNAUTHORIZED', `Session token works for the cookie path but Bearer auth failed (${me.status}).`, { hint: 'The control-plane worker may not yet accept Bearer tokens on /v1/sessions/me.' });
|
|
286
|
-
reporter.done(undefined, 1);
|
|
287
|
-
return { exitCode: 1 };
|
|
288
|
-
}
|
|
289
|
-
const meBody = (await me.json());
|
|
290
|
-
if (!meBody.user) {
|
|
291
|
-
reporter.error('HSX_E_API_UNEXPECTED', '/v1/sessions/me returned an unexpected shape.', {});
|
|
292
|
-
reporter.done(undefined, 1);
|
|
293
|
-
return { exitCode: 1 };
|
|
294
|
-
}
|
|
295
|
-
const session = {
|
|
296
|
-
sessionToken,
|
|
297
|
-
expiresAt,
|
|
298
|
-
email: meBody.user.email,
|
|
299
|
-
userId: meBody.user.id,
|
|
300
|
-
};
|
|
301
|
-
await setUserSession(session, defaultStorePath());
|
|
302
|
-
reporter.info(`Logged in as ${meBody.user.email} (user_id=${meBody.user.id}).`);
|
|
303
|
-
reporter.info(`Session stored in ${defaultStorePath()}. Use \`hs-x logout\` to revoke.`);
|
|
304
|
-
reporter.done(undefined, 0);
|
|
305
|
-
return { exitCode: 0 };
|
|
306
145
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
146
|
+
/** GET /v1/users/me/accounts; best-effort (empty list on any failure). */
|
|
147
|
+
function listUserAccounts(http, controlPlaneUrl, token) {
|
|
148
|
+
return Effect.gen(function* () {
|
|
149
|
+
const response = yield* http
|
|
150
|
+
.execute(HttpClientRequest.get(new URL('/v1/users/me/accounts', controlPlaneUrl)).pipe(HttpClientRequest.setHeader('authorization', `Bearer ${token}`)))
|
|
151
|
+
.pipe(Effect.option);
|
|
152
|
+
if (Option.isNone(response) || !response.value.ok)
|
|
153
|
+
return [];
|
|
154
|
+
const parsed = yield* Effect.tryPromise(() => response.value.json()).pipe(Effect.orElseSucceed(() => ({ accounts: undefined })));
|
|
155
|
+
const raw = Array.isArray(parsed.accounts) ? parsed.accounts : [];
|
|
156
|
+
const list = [];
|
|
157
|
+
for (const entry of raw) {
|
|
158
|
+
if (!entry || typeof entry !== 'object')
|
|
159
|
+
continue;
|
|
160
|
+
const e = entry;
|
|
161
|
+
if (typeof e.accountId !== 'string')
|
|
162
|
+
continue;
|
|
163
|
+
list.push({
|
|
164
|
+
accountId: e.accountId,
|
|
165
|
+
...(typeof e.displayName === 'string' ? { displayName: e.displayName } : {}),
|
|
166
|
+
});
|
|
319
167
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
}
|
|
323
|
-
function extractCookieExpires(setCookie) {
|
|
324
|
-
const match = /Expires=([^;]+)/i.exec(setCookie);
|
|
325
|
-
if (!match || !match[1])
|
|
326
|
-
return undefined;
|
|
327
|
-
const parsed = new Date(match[1]);
|
|
328
|
-
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : undefined;
|
|
329
|
-
}
|
|
330
|
-
function resolveFlag(argv, name) {
|
|
331
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
332
|
-
if (argv[i] === name)
|
|
333
|
-
return argv[i + 1];
|
|
334
|
-
if (argv[i]?.startsWith(`${name}=`))
|
|
335
|
-
return argv[i].slice(name.length + 1);
|
|
336
|
-
}
|
|
337
|
-
return undefined;
|
|
168
|
+
return list;
|
|
169
|
+
});
|
|
338
170
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
`;
|
|
171
|
+
/** Pick the HS-X account to bind: flag → single → interactive prompt → fail. */
|
|
172
|
+
function resolveTargetAccount(input) {
|
|
173
|
+
return Effect.gen(function* () {
|
|
174
|
+
if (input.flagAccount) {
|
|
175
|
+
const match = input.list.find((a) => a.accountId === input.flagAccount);
|
|
176
|
+
return match ?? { accountId: input.flagAccount };
|
|
177
|
+
}
|
|
178
|
+
if (input.list.length === 1 && input.list[0])
|
|
179
|
+
return input.list[0];
|
|
180
|
+
if (input.list.length === 0) {
|
|
181
|
+
return yield* Effect.fail(cliError('HSX_E_INPUT_BAD_FIELD', 'This token has no HS-X accounts.'));
|
|
182
|
+
}
|
|
183
|
+
if (!input.interactive) {
|
|
184
|
+
return yield* Effect.fail(cliError('HSX_E_INPUT_BAD_FIELD', 'This token grants multiple HS-X accounts; pass --account <id>.'));
|
|
185
|
+
}
|
|
186
|
+
const picked = yield* Effect.promise(() => promptSelect({
|
|
187
|
+
message: 'Which HS-X account?',
|
|
188
|
+
options: input.list.map((a) => ({
|
|
189
|
+
value: a.accountId,
|
|
190
|
+
label: a.displayName ?? a.accountId,
|
|
191
|
+
...(a.displayName ? { description: a.accountId } : {}),
|
|
192
|
+
})),
|
|
193
|
+
}));
|
|
194
|
+
const match = input.list.find((a) => a.accountId === picked);
|
|
195
|
+
if (!match)
|
|
196
|
+
return yield* Effect.fail(exitWith(130));
|
|
197
|
+
return match;
|
|
198
|
+
});
|
|
368
199
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
//
|
|
375
|
-
// `login` is QUARANTINED: the device-authorization flow binds to an imperative
|
|
376
|
-
// browser/loopback/poll orchestration whose internal purity is out of scope for
|
|
377
|
-
// this pass. The native `loginCmd` reconstructs the legacy argv from its typed
|
|
378
|
-
// options and delegates to the existing `loginCommand` Promise via
|
|
379
|
-
// `Effect.tryPromise`; only the command boundary is Effect. A non-zero
|
|
380
|
-
// `CliResult.exitCode` is surfaced as a silent `exitWith(code)` (output already
|
|
381
|
-
// rendered by the legacy body) so `runHandler` does not re-render.
|
|
382
|
-
//
|
|
383
|
-
// JSON-shape NOTE (flagged): `logout --json` emits the bespoke `{ok:true}` flat
|
|
384
|
-
// line, then the entry reporter's own `done` envelope — identical to the legacy
|
|
385
|
-
// sequence; `login --json` shapes are owned entirely by the quarantined legacy
|
|
386
|
-
// body. TODO(phase-5): reconcile these payloads with the standard envelope.
|
|
387
|
-
const magicLinkOption = Options.boolean('magic-link').pipe(Options.withDefault(false));
|
|
388
|
-
const emailOption = Options.text('email').pipe(Options.optional);
|
|
200
|
+
/* -------------------------------------------------------------------------- */
|
|
201
|
+
/* Native @effect/cli commands */
|
|
202
|
+
/* -------------------------------------------------------------------------- */
|
|
203
|
+
const accountOption = Options.text('account').pipe(Options.optional);
|
|
204
|
+
const tokenOption = Options.text('token').pipe(Options.optional);
|
|
389
205
|
export const loginCmd = Command.make('login', {
|
|
390
206
|
json: jsonOption,
|
|
391
|
-
|
|
392
|
-
|
|
207
|
+
account: accountOption,
|
|
208
|
+
token: tokenOption,
|
|
393
209
|
controlPlaneUrl: controlPlaneUrlOption,
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
if (opts.json)
|
|
400
|
-
argv.push('--json');
|
|
401
|
-
if (opts.magicLink)
|
|
402
|
-
argv.push('--magic-link');
|
|
403
|
-
if (Option.isSome(opts.email))
|
|
404
|
-
argv.push('--email', opts.email.value);
|
|
405
|
-
if (Option.isSome(opts.controlPlaneUrl)) {
|
|
406
|
-
argv.push('--control-plane-url', opts.controlPlaneUrl.value);
|
|
407
|
-
}
|
|
408
|
-
const cwd = Option.getOrUndefined(opts.cwd);
|
|
409
|
-
yield* runQuarantined(opts.json, () => loginCommand(cwd === undefined ? { argv } : { argv, cwd }));
|
|
210
|
+
}, (opts) => runHandler('login', { json: opts.json }, loginBody({
|
|
211
|
+
json: opts.json,
|
|
212
|
+
account: opts.account,
|
|
213
|
+
token: opts.token,
|
|
214
|
+
controlPlaneUrl: opts.controlPlaneUrl,
|
|
410
215
|
})));
|
|
411
|
-
export const logoutCmd = Command.make('logout', { json: jsonOption, controlPlaneUrl: controlPlaneUrlOption
|
|
216
|
+
export const logoutCmd = Command.make('logout', { json: jsonOption, controlPlaneUrl: controlPlaneUrlOption }, (opts) => runHandler('logout', { json: opts.json }, logoutBody(opts)));
|
|
412
217
|
function logoutBody(opts) {
|
|
413
218
|
return Effect.gen(function* () {
|
|
414
219
|
const accounts = yield* AccountStore;
|
|
415
220
|
const http = yield* CliHttp;
|
|
416
221
|
const reporter = yield* Reporter;
|
|
417
222
|
const store = yield* accounts.load;
|
|
418
|
-
|
|
223
|
+
const active = store.defaultAccountId ? store.accounts[store.defaultAccountId] : undefined;
|
|
224
|
+
if (!active?.session) {
|
|
419
225
|
yield* reporter.info('Not logged in.');
|
|
420
226
|
yield* reporter.done(undefined, 0);
|
|
421
227
|
return;
|
|
@@ -424,13 +230,13 @@ function logoutBody(opts) {
|
|
|
424
230
|
onNone: () => resolveControlPlaneUrl([]),
|
|
425
231
|
onSome: (value) => resolveControlPlaneUrl(['--control-plane-url', value]),
|
|
426
232
|
});
|
|
427
|
-
// best-effort revoke; ignore any transport/status failure
|
|
233
|
+
// best-effort revoke; ignore any transport/status failure
|
|
428
234
|
yield* http
|
|
429
|
-
.execute(HttpClientRequest.post(new URL('/v1/sessions/logout', controlPlaneUrl)).pipe(HttpClientRequest.setHeader('authorization', `Bearer ${
|
|
235
|
+
.execute(HttpClientRequest.post(new URL('/v1/sessions/logout', controlPlaneUrl)).pipe(HttpClientRequest.setHeader('authorization', `Bearer ${active.session.sessionToken}`)))
|
|
430
236
|
.pipe(Effect.ignore);
|
|
431
|
-
yield* accounts.
|
|
237
|
+
yield* accounts.clearAccountSession(active.id);
|
|
432
238
|
if (!opts.json)
|
|
433
|
-
yield* reporter.info(
|
|
239
|
+
yield* reporter.info(`Logged out of ${active.displayName}. Local token cleared.`);
|
|
434
240
|
else
|
|
435
241
|
yield* Effect.sync(() => process.stdout.write(`${JSON.stringify({ ok: true })}\n`));
|
|
436
242
|
yield* reporter.done(undefined, 0);
|