@golden-frijoles/cli 0.4.1 → 0.5.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.
@@ -39,6 +39,7 @@ const args_1 = require("../args");
39
39
  const credentials_1 = require("../credentials");
40
40
  const exit_codes_1 = require("../exit-codes");
41
41
  const output_1 = require("../output");
42
+ const device_login_1 = require("./device-login");
42
43
  /**
43
44
  * Read a token from stdin when `--token` was not given.
44
45
  *
@@ -91,25 +92,44 @@ async function readTokenFromStdin(context) {
91
92
  }
92
93
  exports.loginCommand = {
93
94
  path: ['login'],
94
- summary: 'save a CLI token for this machine',
95
+ summary: 'sign this machine in (browser), or save a CLI token',
95
96
  usage: 'gf login [--token <token>] [--api <url>]',
96
97
  needsAuth: false,
97
- detail: `Mint a token at /app/setup/cli in the console, then paste it here.
98
+ detail: `At a terminal, opens your browser: confirm the code it shows and this machine
99
+ is signed in. If browser sign-in is not available, it asks for a token instead — mint
100
+ one in the console under Setup › CLI access and paste it.
98
101
 
99
- With no --token, the token is read from STDIN — so it never appears in your shell
100
- history or in \`ps\`. For CI, set GOLDEN_FRIJOLES_TOKEN instead and skip this verb;
101
- nothing is written to disk in that case.`,
102
+ With --token, or a token piped in, no browser is involved. A pasted token is read
103
+ from STDIN — so it never appears in your shell history or in \`ps\`. For CI, set
104
+ GOLDEN_FRIJOLES_TOKEN instead and skip this verb; nothing is written to disk in that case.`,
102
105
  flags: [
103
106
  { name: 'token', value: '<token>', describe: 'the token, instead of reading stdin' },
104
107
  { name: 'api', value: '<url>', describe: `the deployment (default: ${credentials_1.DEFAULT_API_URL})` },
105
108
  ],
106
109
  async run(context) {
107
- const token = (0, args_1.flagValue)(context.args, 'token')?.trim() || (await readTokenFromStdin(context));
110
+ const apiUrl = (0, credentials_1.normalizeApiUrl)((0, args_1.flagValue)(context.args, 'api')?.trim() || context.env.GOLDEN_FRIJOLES_URL?.trim() || credentials_1.DEFAULT_API_URL);
111
+ let token = (0, args_1.flagValue)(context.args, 'token')?.trim() || null;
112
+ // account-from-the-terminal D9 — a person at a terminal (nothing piped, no --token, not --json)
113
+ // signs in through the browser. A server that predates the device routes, or has the
114
+ // `auth.terminal_sign_in_enabled` kill switch off, answers 404 — and that, or no network, falls
115
+ // through to the paste prompt below, unchanged. Piping and --token never reach this.
116
+ if (!token && process.stdin.isTTY && !context.emit.json) {
117
+ const device = await (0, device_login_1.deviceLogin)(context, apiUrl);
118
+ if (device.kind === 'token')
119
+ token = device.token;
120
+ else if (device.kind === 'refused') {
121
+ context.emit.fail(device.code, device.message);
122
+ return (0, exit_codes_1.exitForServerCode)(device.code);
123
+ }
124
+ else
125
+ context.emit.note(`Browser sign-in is not available here (${device.why}).`);
126
+ }
127
+ if (!token)
128
+ token = await readTokenFromStdin(context);
108
129
  if (!token) {
109
130
  context.emit.fail('invalid', 'No token supplied. Pass --token, or pipe one into `gf login`.');
110
131
  return exit_codes_1.EXIT.USAGE;
111
132
  }
112
- const apiUrl = (0, credentials_1.normalizeApiUrl)((0, args_1.flagValue)(context.args, 'api')?.trim() || context.env.GOLDEN_FRIJOLES_URL?.trim() || credentials_1.DEFAULT_API_URL);
113
133
  // ⚠️ VERIFY before saving. Writing an unverified token produces a credentials file that looks
114
134
  // fine and fails on every later command with an error about that command — which is how someone
115
135
  // spends an afternoon debugging `gf flags ls` when the real answer is "that paste was truncated".
@@ -149,7 +169,7 @@ exports.logoutCommand = {
149
169
  usage: 'gf logout',
150
170
  needsAuth: false,
151
171
  detail: `Removes the local credential only. It does NOT revoke the token — anything else
152
- holding it still works. Revoke at /app/setup/cli when that is what you mean.`,
172
+ holding it still works. Revoke it in the console (Setup › CLI access) when that is what you mean.`,
153
173
  flags: [],
154
174
  async run(context) {
155
175
  const path = (0, credentials_1.credentialsPath)(context.env);
@@ -160,7 +180,7 @@ exports.logoutCommand = {
160
180
  // Overwritten with an empty token rather than unlinked: `readCredentials` treats it as "not
161
181
  // logged in", the file keeps its 0600 mode, and `gf doctor` can still report where it looked.
162
182
  (0, credentials_1.writeCredentials)({ token: '', apiUrl: credentials_1.DEFAULT_API_URL }, context.env);
163
- context.emit.ok({ removed: true, credentialsPath: path }, `Removed the saved credential from ${path}. The token itself is NOT revoked — revoke it at /app/setup/cli.`);
183
+ context.emit.ok({ removed: true, credentialsPath: path }, `Removed the saved credential from ${path}. The token itself is NOT revoked — revoke it in the console under Setup › CLI access.`);
164
184
  return exit_codes_1.EXIT.OK;
165
185
  },
166
186
  };
@@ -0,0 +1,14 @@
1
+ import type { CommandContext } from '../command';
2
+ export type DeviceLoginResult = {
3
+ kind: 'token';
4
+ token: string;
5
+ } | {
6
+ kind: 'fallback';
7
+ why: string;
8
+ } | {
9
+ kind: 'refused';
10
+ code: string;
11
+ message: string;
12
+ };
13
+ export declare function isSameOriginHttp(url: string, apiUrl: string): boolean;
14
+ export declare function deviceLogin(context: CommandContext, apiUrl: string): Promise<DeviceLoginResult>;
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ // account-from-the-terminal · Sprint 2, Story 2.2 — `gf login` through the browser (epic D9).
3
+ //
4
+ // Start a pairing, show the code, open the browser, poll until the person confirms in a signed-in
5
+ // page, and hand back the token the server minted for this machine. Everything that can go wrong
6
+ // before the person is involved — a server that predates these routes, the flag killed, no network —
7
+ // returns `fallback`, and `gf login` asks for a pasted token exactly as it always did.
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.isSameOriginHttp = isSameOriginHttp;
10
+ exports.deviceLogin = deviceLogin;
11
+ const node_child_process_1 = require("node:child_process");
12
+ const node_os_1 = require("node:os");
13
+ const api_1 = require("../api");
14
+ const version_1 = require("../version");
15
+ /** Opens a URL in the default browser, best-effort and dependency-free. A failure is not an error. */
16
+ function openBrowser(url, env) {
17
+ if (env.GOLDEN_FRIJOLES_NO_BROWSER === '1')
18
+ return;
19
+ const os = (0, node_os_1.platform)();
20
+ const [command, args] = os === 'darwin'
21
+ ? ['open', [url]]
22
+ : os === 'win32'
23
+ ? // NOT `cmd /c start`: cmd re-parses an unquoted argument, so a `&` in even a same-origin URL
24
+ // runs a second command (fresh reviewer, PR #280 round 2). rundll32 hands the URL to the
25
+ // protocol handler without a shell.
26
+ ['rundll32', ['url.dll,FileProtocolHandler', url]]
27
+ : ['xdg-open', [url]];
28
+ try {
29
+ const child = (0, node_child_process_1.spawn)(command, args, { stdio: 'ignore', detached: true });
30
+ child.on('error', () => undefined);
31
+ child.unref();
32
+ }
33
+ catch {
34
+ // The URL is printed either way; a machine with no browser just follows it by hand.
35
+ }
36
+ }
37
+ function isSameOriginHttp(url, apiUrl) {
38
+ try {
39
+ const target = new URL(url);
40
+ return ((target.protocol === 'https:' || target.protocol === 'http:') &&
41
+ target.origin === new URL(apiUrl).origin);
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ /** A finite, non-negative number (floored at 1 s), or the fallback. */
48
+ function positiveOr(value, fallback) {
49
+ const n = Number(value);
50
+ // A 1 s floor: a buggy server answering `interval: 0` must not turn the poll into a tight loop.
51
+ return Number.isFinite(n) && n >= 0 ? Math.max(1, n) : fallback;
52
+ }
53
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
54
+ async function deviceLogin(context, apiUrl) {
55
+ // No credential yet — that is the point of this flow. The routes ignore Authorization.
56
+ const client = (0, api_1.createApiClient)({
57
+ baseUrl: apiUrl,
58
+ token: '',
59
+ userAgent: `golden-frijoles-cli/${version_1.VERSION}`,
60
+ fetchImpl: context.fetchImpl,
61
+ });
62
+ const label = `${(0, node_os_1.hostname)()} (${(0, node_os_1.platform)()})`.slice(0, 80);
63
+ const started = await client.post('api/v1/cli/device', { label });
64
+ if (started.kind === 'network')
65
+ return { kind: 'fallback', why: started.message };
66
+ if (started.kind === 'error') {
67
+ // 404 (`disabled`, `not_found`): an older server, or the kill switch. Anything else from the
68
+ // START is also a reason to offer the paste path rather than strand the person.
69
+ return { kind: 'fallback', why: started.message };
70
+ }
71
+ const { deviceCode, userCode, verificationUrl } = started.body;
72
+ let interval = positiveOr(started.body.interval, 5);
73
+ const deadline = Date.now() + Math.max(1, Number(started.body.expiresIn) || 600) * 1000;
74
+ context.emit.note(`Your code: ${userCode}\n` +
75
+ `Opening ${verificationUrl}\n` +
76
+ 'Check that the browser shows the same code, then confirm. (Not opening? Visit the link yourself.)');
77
+ // Only a URL on the deployment we are signing in to, over http(s), is handed to the OS opener —
78
+ // `open`/`start` will run whatever a hostile `--api` server puts here (fresh reviewer, PR #280).
79
+ // Anything else is printed above and left for the person to follow, or not.
80
+ if (isSameOriginHttp(verificationUrl, apiUrl))
81
+ openBrowser(verificationUrl, context.env);
82
+ while (Date.now() < deadline) {
83
+ await sleep(interval * 1000);
84
+ const polled = await client.post('api/v1/cli/device/token', { deviceCode });
85
+ if (polled.kind === 'network')
86
+ continue; // a blip; the deadline still bounds the wait
87
+ if (polled.kind === 'error') {
88
+ if (polled.code === 'server_error')
89
+ continue;
90
+ return { kind: 'refused', code: polled.code, message: polled.message };
91
+ }
92
+ if (polled.body.status === 'approved' && typeof polled.body.token === 'string') {
93
+ return { kind: 'token', token: polled.body.token };
94
+ }
95
+ if (polled.body.status === 'slow_down')
96
+ interval = Math.max(interval + 5, positiveOr(polled.body.interval, 0));
97
+ }
98
+ return {
99
+ kind: 'refused',
100
+ code: 'not_found',
101
+ message: 'That sign-in code expired before it was confirmed. Run `gf login` again.',
102
+ };
103
+ }
@@ -118,7 +118,7 @@ exports.doctorCommand = {
118
118
  status: 'fail',
119
119
  // Unknown, revoked and expired are one answer at the server by design, so `doctor` must not
120
120
  // invent a distinction it cannot have. It names all three and one remedy that covers them.
121
- detail: 'The deployment rejected this credential — unknown, revoked or expired. Mint a new one at /app/setup/cli.',
121
+ detail: 'The deployment rejected this credential — unknown, revoked or expired. Run `gf login` again (or mint one in the console under Setup › CLI access).',
122
122
  });
123
123
  return report(context, checks);
124
124
  }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.4.1";
1
+ export declare const VERSION = "0.5.0";
package/dist/version.js CHANGED
@@ -10,4 +10,4 @@ exports.VERSION = void 0;
10
10
  //
11
11
  // It is a literal, and `version.test.ts` asserts it equals `package.json`'s — so the two cannot
12
12
  // drift, and the drift is caught by the unit gate rather than by someone reading `gf --version`.
13
- exports.VERSION = '0.4.1';
13
+ exports.VERSION = '0.5.0';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@golden-frijoles/cli",
3
3
  "private": false,
4
- "version": "0.4.1",
4
+ "version": "0.5.0",
5
5
  "description": "The Golden Frijoles CLI — create, roll out and kill feature flags from a terminal, or from an agent.",
6
6
  "bin": {
7
7
  "gf": "./dist/bin.js"