@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.
- package/dist/commands/auth.js +29 -9
- package/dist/commands/device-login.d.ts +14 -0
- package/dist/commands/device-login.js +103 -0
- package/dist/commands/doctor.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/commands/auth.js
CHANGED
|
@@ -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
|
|
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: `
|
|
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
|
|
100
|
-
history or in \`ps\`. For CI, set
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
13
|
+
exports.VERSION = '0.5.0';
|
package/package.json
CHANGED