@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.
Files changed (137) hide show
  1. package/dist/account-store.d.ts +45 -22
  2. package/dist/account-store.d.ts.map +1 -1
  3. package/dist/account-store.js +73 -16
  4. package/dist/account-store.js.map +1 -1
  5. package/dist/cli/index.d.ts.map +1 -1
  6. package/dist/cli/index.js +170 -13
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/cli/kit.d.ts +1 -1
  9. package/dist/cli/kit.d.ts.map +1 -1
  10. package/dist/cli/kit.js.map +1 -1
  11. package/dist/commands/account.d.ts +6 -6
  12. package/dist/commands/account.d.ts.map +1 -1
  13. package/dist/commands/account.js +25 -23
  14. package/dist/commands/account.js.map +1 -1
  15. package/dist/commands/api.d.ts +1 -1
  16. package/dist/commands/completion.d.ts.map +1 -1
  17. package/dist/commands/completion.js +6 -4
  18. package/dist/commands/completion.js.map +1 -1
  19. package/dist/commands/connect.d.ts +16 -0
  20. package/dist/commands/connect.d.ts.map +1 -1
  21. package/dist/commands/connect.js +143 -17
  22. package/dist/commands/connect.js.map +1 -1
  23. package/dist/commands/control-plane-read.d.ts +3 -3
  24. package/dist/commands/control-plane-read.d.ts.map +1 -1
  25. package/dist/commands/control-plane-read.js +21 -33
  26. package/dist/commands/control-plane-read.js.map +1 -1
  27. package/dist/commands/deploy-promote.d.ts +1 -1
  28. package/dist/commands/deploy-promote.d.ts.map +1 -1
  29. package/dist/commands/deploy-promote.js +3 -3
  30. package/dist/commands/deploy-promote.js.map +1 -1
  31. package/dist/commands/deploy.d.ts.map +1 -1
  32. package/dist/commands/deploy.js +39 -2
  33. package/dist/commands/deploy.js.map +1 -1
  34. package/dist/commands/dev.d.ts.map +1 -1
  35. package/dist/commands/dev.js +11 -0
  36. package/dist/commands/dev.js.map +1 -1
  37. package/dist/commands/doctor.d.ts +1 -1
  38. package/dist/commands/doctor.js +2 -2
  39. package/dist/commands/doctor.js.map +1 -1
  40. package/dist/commands/flags.d.ts +1 -1
  41. package/dist/commands/flags.d.ts.map +1 -1
  42. package/dist/commands/flags.js +59 -35
  43. package/dist/commands/flags.js.map +1 -1
  44. package/dist/commands/help-command.d.ts +14 -0
  45. package/dist/commands/help-command.d.ts.map +1 -1
  46. package/dist/commands/help-command.js +274 -20
  47. package/dist/commands/help-command.js.map +1 -1
  48. package/dist/commands/init.d.ts +11 -8
  49. package/dist/commands/init.d.ts.map +1 -1
  50. package/dist/commands/init.js +435 -246
  51. package/dist/commands/init.js.map +1 -1
  52. package/dist/commands/link.d.ts +1 -26
  53. package/dist/commands/link.d.ts.map +1 -1
  54. package/dist/commands/link.js +11 -236
  55. package/dist/commands/link.js.map +1 -1
  56. package/dist/commands/login.d.ts +4 -12
  57. package/dist/commands/login.d.ts.map +1 -1
  58. package/dist/commands/login.js +196 -390
  59. package/dist/commands/login.js.map +1 -1
  60. package/dist/commands/migrate.d.ts +2 -7
  61. package/dist/commands/migrate.d.ts.map +1 -1
  62. package/dist/commands/migrate.js +84 -201
  63. package/dist/commands/migrate.js.map +1 -1
  64. package/dist/commands/rollback.d.ts +1 -20
  65. package/dist/commands/rollback.d.ts.map +1 -1
  66. package/dist/commands/rollback.js +6 -275
  67. package/dist/commands/rollback.js.map +1 -1
  68. package/dist/commands/status.d.ts +1 -1
  69. package/dist/commands/status.js +1 -1
  70. package/dist/commands/status.js.map +1 -1
  71. package/dist/commands/unlink.d.ts +1 -21
  72. package/dist/commands/unlink.d.ts.map +1 -1
  73. package/dist/commands/unlink.js +12 -94
  74. package/dist/commands/unlink.js.map +1 -1
  75. package/dist/commands/update.js +7 -7
  76. package/dist/commands/update.js.map +1 -1
  77. package/dist/commands/validate.d.ts +2 -2
  78. package/dist/commands/validate.d.ts.map +1 -1
  79. package/dist/commands/validate.js +1 -1
  80. package/dist/commands/validate.js.map +1 -1
  81. package/dist/config.d.ts +1 -0
  82. package/dist/config.d.ts.map +1 -1
  83. package/dist/config.js +7 -0
  84. package/dist/config.js.map +1 -1
  85. package/dist/constants.d.ts +1 -1
  86. package/dist/constants.d.ts.map +1 -1
  87. package/dist/constants.js +5 -1
  88. package/dist/constants.js.map +1 -1
  89. package/dist/control-plane-fetch.d.ts.map +1 -1
  90. package/dist/control-plane-fetch.js +2 -2
  91. package/dist/control-plane-fetch.js.map +1 -1
  92. package/dist/control-plane-loader.d.ts +1 -1
  93. package/dist/control-plane-loader.js +1 -1
  94. package/dist/errors-registry.js +1 -1
  95. package/dist/errors-registry.js.map +1 -1
  96. package/dist/help.d.ts.map +1 -1
  97. package/dist/help.js +3 -2
  98. package/dist/help.js.map +1 -1
  99. package/dist/init/templates.d.ts +11 -3
  100. package/dist/init/templates.d.ts.map +1 -1
  101. package/dist/init/templates.js +96 -25
  102. package/dist/init/templates.js.map +1 -1
  103. package/dist/project-binding.d.ts +17 -0
  104. package/dist/project-binding.d.ts.map +1 -0
  105. package/dist/project-binding.js +30 -0
  106. package/dist/project-binding.js.map +1 -0
  107. package/dist/prompt.d.ts +21 -0
  108. package/dist/prompt.d.ts.map +1 -1
  109. package/dist/prompt.js +186 -50
  110. package/dist/prompt.js.map +1 -1
  111. package/dist/reporter/human.d.ts +5 -0
  112. package/dist/reporter/human.d.ts.map +1 -1
  113. package/dist/reporter/human.js +48 -18
  114. package/dist/reporter/human.js.map +1 -1
  115. package/dist/reporter/json.d.ts +6 -0
  116. package/dist/reporter/json.d.ts.map +1 -1
  117. package/dist/reporter/json.js +16 -0
  118. package/dist/reporter/json.js.map +1 -1
  119. package/dist/reporter/style.d.ts +1 -1
  120. package/dist/reporter/style.d.ts.map +1 -1
  121. package/dist/reporter/style.js +10 -3
  122. package/dist/reporter/style.js.map +1 -1
  123. package/dist/reporter/types.d.ts +9 -0
  124. package/dist/reporter/types.d.ts.map +1 -1
  125. package/dist/reporter/types.js.map +1 -1
  126. package/dist/services/account-store.d.ts +9 -4
  127. package/dist/services/account-store.d.ts.map +1 -1
  128. package/dist/services/account-store.js +46 -22
  129. package/dist/services/account-store.js.map +1 -1
  130. package/dist/services/control-plane.js +3 -2
  131. package/dist/services/control-plane.js.map +1 -1
  132. package/dist/services/live.d.ts +1 -1
  133. package/dist/services/reporter.d.ts +3 -0
  134. package/dist/services/reporter.d.ts.map +1 -1
  135. package/dist/services/reporter.js +2 -0
  136. package/dist/services/reporter.js.map +1 -1
  137. package/package.json +11 -8
@@ -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, Schema, schemas } from '@hs-x/types';
5
- import { Effect, Option } from 'effect';
6
- import { exitWith } from '../cli-error.js';
7
- import { jsonOption, controlPlaneUrlOption, cwdOption, runHandler, runQuarantined, } from '../cli/kit.js';
8
- import { DEFAULT_CONTROL_PLANE_URL, resolveControlPlaneUrl } from '../config.js';
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 { getMachineId } from '../machine-id.js';
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
- import os from 'node:os';
19
- const SESSION_COOKIE_NAME = '__Secure-hsx_session';
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
- export async function loginCommand(input) {
22
- const argv = input.argv;
23
- const sub = argv[0];
24
- const json = argv.includes('--json');
25
- if (sub === 'help' || sub === '--help' || sub === '-h') {
26
- emitHelp(loginHelpText(), json);
27
- return { exitCode: 0 };
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
- /* Device-authorization flow (default) */
25
+ /* hs-x login — paste a per-account API token (HubSpot `hs`-style) */
38
26
  /* -------------------------------------------------------------------------- */
39
- async function deviceFlowLogin(input) {
40
- const argv = input.argv;
41
- const json = argv.includes('--json');
42
- const reporter = createReporter({ command: 'login', argv, entry: true });
43
- if (!json)
44
- reporter.banner();
45
- const controlPlaneUrl = resolveControlPlaneUrl(argv);
46
- if (!json)
47
- reporter.header(`device login → ${controlPlaneUrl}`);
48
- const machineId = await getMachineId();
49
- const deviceName = os.hostname() || 'unknown-device';
50
- const cwd = input.cwd ?? process.cwd();
51
- // Step 1: create the pairing (unauthenticated — the CLI has no session yet).
52
- let created;
53
- try {
54
- const payload = Schema.decodeUnknownSync(schemas.CliLoginPairingCreateRequest)({
55
- machineId,
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
- const response = await executeCliHttp(HttpClientRequest.post(new URL('/v1/cli/login/pairings', controlPlaneUrl)).pipe(HttpClientRequest.bodyText(JSON.stringify(payload), 'application/json')));
60
- if (!response.ok) {
61
- const body = await response.text().catch(() => '');
62
- reporter.error('HSX_E_API_UNEXPECTED', `Control plane refused to start a login pairing (${response.status}).`, { hint: body.slice(0, 200) || 'Check your network and try again.' });
63
- reporter.done(undefined, 1);
64
- return { exitCode: 1 };
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
- created = Schema.decodeUnknownSync(schemas.CliLoginPairingCreateResponse)(await response.json());
67
- }
68
- catch (error) {
69
- reporter.error('HSX_E_API_UNEXPECTED', 'Could not start a login pairing.', {
70
- hint: error instanceof Error ? error.message : String(error),
71
- });
72
- reporter.done(undefined, 1);
73
- return { exitCode: 1 };
74
- }
75
- // Step 2/3: show the code and open the dashboard approval page. The
76
- // pairingId is not a secret — the dashboard needs it to call /approve.
77
- const authorizeUrl = cliAuthorizeUrl(created.userCode, created.pairingId);
78
- if (json) {
79
- process.stdout.write(`${JSON.stringify({
80
- ok: true,
81
- command: 'login',
82
- mode: 'device',
83
- status: 'pending',
84
- userCode: created.userCode,
85
- pairingId: created.pairingId,
86
- verificationUri: authorizeUrl,
87
- })}\n`);
88
- }
89
- else {
90
- reporter.block([
91
- `Code ${created.userCode}`,
92
- `Approve ${authorizeUrl}`,
93
- `Device ${deviceName}`,
94
- ].join('\n'));
95
- await openInBrowser(authorizeUrl);
96
- reporter.info('Waiting for approval in your browser…');
97
- }
98
- // Step 4: poll until approved / expired / timeout. The deviceCode is the
99
- // poll bearer; it is held only by this CLI process.
100
- let result;
101
- try {
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
- reporter.done(undefined, 1);
129
- return { exitCode: 1 };
130
- }
131
- // Step 5: persist the PAT as the bearer session.
132
- const session = {
133
- sessionToken: result.token,
134
- expiresAt: result.expiresAt ?? new Date(Date.now() + THIRTY_DAYS_MS).toISOString(),
135
- email: result.email ?? '',
136
- userId: result.userId ?? '',
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 if (response.status === 404) {
173
- throw new Error('Login pairing not found (it may have expired or been revoked).');
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
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
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
- /* Magic-link flow (--magic-link fallback) */
188
- /* -------------------------------------------------------------------------- */
189
- async function magicLinkLogin(input) {
190
- const argv = input.argv;
191
- const json = argv.includes('--json');
192
- const reporter = createReporter({ command: 'login', argv, entry: true });
193
- if (!json)
194
- reporter.banner();
195
- const controlPlaneUrl = resolveControlPlaneUrl(argv);
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
- reporter.done(undefined, 10);
209
- return { exitCode: 10 };
210
- }
211
- // Step 1: ask the control plane to mint and email a magic link.
212
- const start = await executeCliHttp(HttpClientRequest.post(new URL('/v1/auth/magic-link/start', controlPlaneUrl)).pipe(HttpClientRequest.bodyText(JSON.stringify({ email, redirectTo: '/' }), 'application/json')));
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
- function extractCookieValue(setCookie, name) {
308
- // wrangler / undici may concatenate multiple Set-Cookie headers with commas.
309
- // Cookie values themselves never contain commas in our case (token is base64url).
310
- for (const segment of setCookie.split(/,\s*(?=[\w-]+=)/)) {
311
- const [pair] = segment.split(';', 1);
312
- if (!pair)
313
- continue;
314
- const eq = pair.indexOf('=');
315
- if (eq <= 0)
316
- continue;
317
- if (pair.slice(0, eq).trim() === name) {
318
- return pair.slice(eq + 1).trim();
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
- return undefined;
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
- function loginHelpText() {
340
- return `hs-x login
341
-
342
- Log the current shell into the HS-X control plane.
343
-
344
- Usage:
345
- hs-x login [--control-plane-url <url>]
346
- hs-x login --magic-link [--email <you@example.com>] [--control-plane-url <url>]
347
-
348
- Default (device authorization):
349
- 1. We create a login pairing on the control plane and show you a short
350
- code (e.g. ABCD-1234), then open the dashboard approval page.
351
- 2. Approve the code in your browser (you choose which account the CLI
352
- token is scoped to). The control plane mints a Personal Access Token.
353
- 3. We poll until approval, then store the PAT in
354
- $XDG_CONFIG_HOME/hs-x/config.json and send it as
355
- Authorization: Bearer <token> on later commands.
356
-
357
- Fallback (--magic-link, for browserless / CI):
358
- 1. We POST your email to the control plane; it sends you a magic-link.
359
- 2. Paste the URL from that email here. We extract the token, call the
360
- consume endpoint ourselves, and store the captured session cookie.
361
-
362
- To leave: \`hs-x logout\` revokes the session on the control plane and
363
- clears the local copy. The default control-plane URL is
364
- ${DEFAULT_CONTROL_PLANE_URL}; override with --control-plane-url or
365
- HSX_CONTROL_PLANE_URL. The dashboard URL defaults to https://app.hs-x.dev;
366
- override with HSX_DASHBOARD_URL.
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
- // ─── Native @effect/cli commands (Phase 4) ──────────────────────────────────
370
- // `logout` is built natively on the `AccountStore` service (`load` +
371
- // `clearUserSession`), with the best-effort control-plane revoke POST going
372
- // through the `CliHttp` service and human output through the `Reporter`
373
- // service byte-for-byte with the legacy `logoutCommand`.
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
- magicLink: magicLinkOption,
392
- email: emailOption,
207
+ account: accountOption,
208
+ token: tokenOption,
393
209
  controlPlaneUrl: controlPlaneUrlOption,
394
- cwd: cwdOption,
395
- }, (opts) => runHandler('login', { json: opts.json }, Effect.gen(function* () {
396
- // Reconstruct the legacy argv the quarantined body parses (it reads
397
- // --json / --magic-link / --email / --control-plane-url off argv).
398
- const argv = ['login'];
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, cwd: cwdOption }, (opts) => runHandler('logout', { json: opts.json }, logoutBody(opts)));
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
- if (!store.userSession) {
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 (matches legacy `.catch`)
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 ${store.userSession.sessionToken}`)))
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.clearUserSession;
237
+ yield* accounts.clearAccountSession(active.id);
432
238
  if (!opts.json)
433
- yield* reporter.info('Logged out. Local session cleared.');
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);