@youkno/edge-cli 1.21.311 → 1.21.313
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/shell.js +59 -1
- package/dist/lib/auth.js +39 -11
- package/package.json +1 -1
- package/src/commands/shell.ts +82 -2
- package/src/lib/auth.ts +41 -13
package/dist/commands/shell.js
CHANGED
|
@@ -102,13 +102,55 @@ async function shellScript(cfg, authHeader, scriptText, options, scriptName = "i
|
|
|
102
102
|
}
|
|
103
103
|
process.stdout.write(body.endsWith("\n") ? body : `${body}\n`);
|
|
104
104
|
}
|
|
105
|
+
const REPL_COMMANDS = new Map([
|
|
106
|
+
["login", {
|
|
107
|
+
description: "re-authenticate this session (local; does not touch the server)",
|
|
108
|
+
// The reverse of `login --shell`, which logs in and then opens the REPL. Without this, a
|
|
109
|
+
// token expiring mid-session meant quitting, re-authenticating and starting over -- and
|
|
110
|
+
// since resolveAccessToken re-reads the token store on every command, the very next line
|
|
111
|
+
// picks up the new session with nothing to invalidate.
|
|
112
|
+
run: async (cfg) => {
|
|
113
|
+
const email = await (0, auth_1.login)(cfg);
|
|
114
|
+
process.stdout.write(`Successfully authenticated as ${email}\n`);
|
|
115
|
+
}
|
|
116
|
+
}],
|
|
117
|
+
["help", {
|
|
118
|
+
description: "list these local commands",
|
|
119
|
+
run: async () => {
|
|
120
|
+
const width = Math.max(...[...REPL_COMMANDS.keys()].map((n) => n.length));
|
|
121
|
+
process.stdout.write("Local commands (handled by the CLI, never sent to the server):\n");
|
|
122
|
+
for (const [name, spec] of REPL_COMMANDS) {
|
|
123
|
+
process.stdout.write(` /${name.padEnd(width)} ${spec.description}\n`);
|
|
124
|
+
}
|
|
125
|
+
// Said explicitly because `help` and `/help` are different commands answering about
|
|
126
|
+
// different things, and nothing else in the REPL would tell you that.
|
|
127
|
+
process.stdout.write("\nFor server commands, use 'help' without the slash.\n");
|
|
128
|
+
}
|
|
129
|
+
}]
|
|
130
|
+
]);
|
|
131
|
+
/**
|
|
132
|
+
* Runs one slash command. The caller routes every slash-prefixed line here, so this never has to
|
|
133
|
+
* decide whether the line was meant for it.
|
|
134
|
+
*/
|
|
135
|
+
async function handleReplCommand(cfg, cmd) {
|
|
136
|
+
const name = cmd.slice(1).trim().toLowerCase();
|
|
137
|
+
const spec = REPL_COMMANDS.get(name);
|
|
138
|
+
if (spec) {
|
|
139
|
+
await spec.run(cfg);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// Unknown slash command: say so rather than forwarding it to the server, which would answer
|
|
143
|
+
// with a confusing parse error about a command that was never meant for it.
|
|
144
|
+
const known = [...REPL_COMMANDS.keys()].map((n) => `/${n}`).join(", ");
|
|
145
|
+
process.stderr.write(`Unknown command: ${cmd}. Available: ${known}\n`);
|
|
146
|
+
}
|
|
105
147
|
async function repl(cfg, options) {
|
|
106
148
|
const rl = node_readline_1.default.createInterface({
|
|
107
149
|
input: process.stdin,
|
|
108
150
|
output: process.stdout,
|
|
109
151
|
terminal: true
|
|
110
152
|
});
|
|
111
|
-
process.stdout.write("Type Ctrl-D to exit, 'help' for help\n");
|
|
153
|
+
process.stdout.write("Type Ctrl-D to exit, 'help' for server commands, '/help' for local ones\n");
|
|
112
154
|
rl.setPrompt(`${cfg.env}> `);
|
|
113
155
|
rl.prompt();
|
|
114
156
|
rl.on("line", async (line) => {
|
|
@@ -117,6 +159,22 @@ async function repl(cfg, options) {
|
|
|
117
159
|
rl.prompt();
|
|
118
160
|
return;
|
|
119
161
|
}
|
|
162
|
+
if (cmd.startsWith("/")) {
|
|
163
|
+
// Input is paused for the duration. readline does not await this handler, so without
|
|
164
|
+
// it a browser login -- which waits on a human -- would let lines typed meanwhile fire
|
|
165
|
+
// concurrently and run against the pre-login token, which is the state /login exists
|
|
166
|
+
// to get out of.
|
|
167
|
+
rl.pause();
|
|
168
|
+
try {
|
|
169
|
+
await handleReplCommand(cfg, cmd);
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
process.stderr.write(`ERROR: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
173
|
+
}
|
|
174
|
+
rl.resume();
|
|
175
|
+
rl.prompt();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
120
178
|
try {
|
|
121
179
|
const authHeader = await (0, auth_1.resolveAuthHeaderFast)(cfg);
|
|
122
180
|
await shellCmd(cfg, authHeader, cmd, Boolean(options.json));
|
package/dist/lib/auth.js
CHANGED
|
@@ -20,6 +20,31 @@ const AUTH_DIR = process.env.EDGE_CLI_AUTH_DIR
|
|
|
20
20
|
: node_path_1.default.join(node_os_1.default.homedir(), ".edge-cli-auth");
|
|
21
21
|
const AUTH_FILE = node_path_1.default.join(AUTH_DIR, "accounts.json");
|
|
22
22
|
const DEFAULT_DEVICE_NAME = "edge-cli";
|
|
23
|
+
/**
|
|
24
|
+
* Value of the `scope` field sent at token exchange, so the server knows this token is for the
|
|
25
|
+
* shell rather than for an app session.
|
|
26
|
+
*
|
|
27
|
+
* Named EXCHANGE_SCOPE_* deliberately: "scope" already means something else in this file -- the
|
|
28
|
+
* product/env bucket of the local token store (`scopeKey`, `db.scopes`). These are unrelated, and
|
|
29
|
+
* the wire field keeps the OAuth-conventional name while the constant says which "scope" it is.
|
|
30
|
+
*
|
|
31
|
+
* `/api/v1/auth/exchange` is not a CLI endpoint -- it is the primary auth path for the mobile apps
|
|
32
|
+
* and the regulars web app too -- so the server cannot infer "this is the CLI" and cannot apply a
|
|
33
|
+
* stricter rule to everyone. This says so explicitly.
|
|
34
|
+
*
|
|
35
|
+
* Not derived from `deviceName`, which already reads "edge-cli": that is a display string for
|
|
36
|
+
* session lists, and quietly promoting cosmetic client input into an authorization input is how a
|
|
37
|
+
* bypass arrives unnoticed.
|
|
38
|
+
*
|
|
39
|
+
* Sent on **exchange only, never on refresh**. A refresh must re-derive the scope from the session
|
|
40
|
+
* the server persisted it on; honouring it from the request would let anyone escalate a non-shell
|
|
41
|
+
* session to a shell one by adding this field to a refresh call.
|
|
42
|
+
*
|
|
43
|
+
* Harmless against a server that predates the field: the API's ObjectMapper sets
|
|
44
|
+
* FAIL_ON_UNKNOWN_PROPERTIES=false, so an older deployment ignores it rather than rejecting the
|
|
45
|
+
* exchange.
|
|
46
|
+
*/
|
|
47
|
+
const EXCHANGE_SCOPE_SHELL = "shell";
|
|
23
48
|
function scopeKey(cfg) {
|
|
24
49
|
return `${cfg.product}/${cfg.env}`;
|
|
25
50
|
}
|
|
@@ -130,7 +155,8 @@ async function authExchangeFirebaseToken(cfg, firebaseToken) {
|
|
|
130
155
|
const resp = await requestTokenEndpoint(cfg, "exchange", {
|
|
131
156
|
firebaseToken,
|
|
132
157
|
deviceId: authDeviceId(),
|
|
133
|
-
deviceName: DEFAULT_DEVICE_NAME
|
|
158
|
+
deviceName: DEFAULT_DEVICE_NAME,
|
|
159
|
+
scope: EXCHANGE_SCOPE_SHELL
|
|
134
160
|
});
|
|
135
161
|
if (!resp?.accessToken) {
|
|
136
162
|
throw new Error("Token exchange did not return accessToken");
|
|
@@ -164,9 +190,10 @@ async function authLogoutRefreshToken(cfg, refreshToken) {
|
|
|
164
190
|
* Exchanging it gets a first-party access token plus a refresh token (30 days by default), which
|
|
165
191
|
* resolveAccessToken already rotates automatically.
|
|
166
192
|
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
193
|
+
* There is NO fallback: a failed exchange fails the login. Storing the callback token as-is meant a
|
|
194
|
+
* server that answered authoritatively -- 401 "User doesn't exist" -- still reported
|
|
195
|
+
* "Successfully authenticated as ..." while storing a credential every API call rejects. See the
|
|
196
|
+
* commit that removed it for what the fallback used to cover and what dropping it costs.
|
|
170
197
|
*/
|
|
171
198
|
async function exchangeCallbackToken(cfg, callbackToken) {
|
|
172
199
|
try {
|
|
@@ -174,13 +201,14 @@ async function exchangeCallbackToken(cfg, callbackToken) {
|
|
|
174
201
|
}
|
|
175
202
|
catch (err) {
|
|
176
203
|
const reason = err instanceof Error ? err.message : String(err);
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
204
|
+
// The identity hint only fits a 401. On a network error or a 5xx, telling someone to check
|
|
205
|
+
// which account they used is misdirection -- the same species of misleading report this
|
|
206
|
+
// whole change removes.
|
|
207
|
+
const hint = reason.includes("(401)")
|
|
208
|
+
? `\nA 401 here usually means this account has no user record on ${cfg.product} -- ` +
|
|
209
|
+
`check you are signing in with the right identity for this product.`
|
|
210
|
+
: "";
|
|
211
|
+
throw new Error(`Login failed: could not exchange for a first-party token (${reason}).${hint}`, { cause: err });
|
|
184
212
|
}
|
|
185
213
|
}
|
|
186
214
|
async function logout(cfg, email) {
|
package/package.json
CHANGED
package/src/commands/shell.ts
CHANGED
|
@@ -4,7 +4,7 @@ import readline from "node:readline";
|
|
|
4
4
|
|
|
5
5
|
import { Command } from "commander";
|
|
6
6
|
|
|
7
|
-
import { resolveAuthHeader, resolveAuthHeaderFast } from "../lib/auth";
|
|
7
|
+
import { login, resolveAuthHeader, resolveAuthHeaderFast } from "../lib/auth";
|
|
8
8
|
import { resolveEffectiveConfig } from "../lib/config";
|
|
9
9
|
import { baseHeaders, checkedText } from "../lib/request";
|
|
10
10
|
import { CliOptions, EffectiveConfig } from "../lib/types";
|
|
@@ -126,6 +126,71 @@ async function shellScript(
|
|
|
126
126
|
process.stdout.write(body.endsWith("\n") ? body : `${body}\n`);
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Client-side REPL commands, distinguished by a leading slash.
|
|
131
|
+
*
|
|
132
|
+
* Slash-prefixed because no server command starts with one, so there is no spelling these can
|
|
133
|
+
* shadow -- every other line in the REPL still goes straight to the server untouched. The sigil
|
|
134
|
+
* also carries real information here: these act on the LOCAL token store, while every other
|
|
135
|
+
* command in this prompt acts on production data.
|
|
136
|
+
*
|
|
137
|
+
* A table rather than an if-chain, with only two entries, because the list is consumed three times
|
|
138
|
+
* -- dispatch, /help, and the unknown-command error. Those drifting apart is the failure this
|
|
139
|
+
* shape prevents; adding a third command updates all three by construction.
|
|
140
|
+
*
|
|
141
|
+
* A Map, not an object literal: the key comes from whatever the user typed, and a plain object
|
|
142
|
+
* would resolve /constructor and /toString through Object.prototype -- truthy, so they passed the
|
|
143
|
+
* lookup and then died on `spec.run is not a function` instead of answering "Unknown command".
|
|
144
|
+
* A Map has no prototype chain to fall through, which makes that class of answer impossible rather
|
|
145
|
+
* than guarded against.
|
|
146
|
+
*/
|
|
147
|
+
type ReplCommand = { description: string; run: (cfg: EffectiveConfig) => Promise<void> };
|
|
148
|
+
|
|
149
|
+
const REPL_COMMANDS = new Map<string, ReplCommand>([
|
|
150
|
+
["login", {
|
|
151
|
+
description: "re-authenticate this session (local; does not touch the server)",
|
|
152
|
+
// The reverse of `login --shell`, which logs in and then opens the REPL. Without this, a
|
|
153
|
+
// token expiring mid-session meant quitting, re-authenticating and starting over -- and
|
|
154
|
+
// since resolveAccessToken re-reads the token store on every command, the very next line
|
|
155
|
+
// picks up the new session with nothing to invalidate.
|
|
156
|
+
run: async (cfg) => {
|
|
157
|
+
const email = await login(cfg);
|
|
158
|
+
process.stdout.write(`Successfully authenticated as ${email}\n`);
|
|
159
|
+
}
|
|
160
|
+
}],
|
|
161
|
+
["help", {
|
|
162
|
+
description: "list these local commands",
|
|
163
|
+
run: async () => {
|
|
164
|
+
const width = Math.max(...[...REPL_COMMANDS.keys()].map((n) => n.length));
|
|
165
|
+
process.stdout.write("Local commands (handled by the CLI, never sent to the server):\n");
|
|
166
|
+
for (const [name, spec] of REPL_COMMANDS) {
|
|
167
|
+
process.stdout.write(` /${name.padEnd(width)} ${spec.description}\n`);
|
|
168
|
+
}
|
|
169
|
+
// Said explicitly because `help` and `/help` are different commands answering about
|
|
170
|
+
// different things, and nothing else in the REPL would tell you that.
|
|
171
|
+
process.stdout.write("\nFor server commands, use 'help' without the slash.\n");
|
|
172
|
+
}
|
|
173
|
+
}]
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Runs one slash command. The caller routes every slash-prefixed line here, so this never has to
|
|
178
|
+
* decide whether the line was meant for it.
|
|
179
|
+
*/
|
|
180
|
+
async function handleReplCommand(cfg: EffectiveConfig, cmd: string): Promise<void> {
|
|
181
|
+
const name = cmd.slice(1).trim().toLowerCase();
|
|
182
|
+
const spec = REPL_COMMANDS.get(name);
|
|
183
|
+
if (spec) {
|
|
184
|
+
await spec.run(cfg);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Unknown slash command: say so rather than forwarding it to the server, which would answer
|
|
189
|
+
// with a confusing parse error about a command that was never meant for it.
|
|
190
|
+
const known = [...REPL_COMMANDS.keys()].map((n) => `/${n}`).join(", ");
|
|
191
|
+
process.stderr.write(`Unknown command: ${cmd}. Available: ${known}\n`);
|
|
192
|
+
}
|
|
193
|
+
|
|
129
194
|
export async function repl(cfg: EffectiveConfig, options: ShellOptions) {
|
|
130
195
|
const rl = readline.createInterface({
|
|
131
196
|
input: process.stdin,
|
|
@@ -133,7 +198,7 @@ export async function repl(cfg: EffectiveConfig, options: ShellOptions) {
|
|
|
133
198
|
terminal: true
|
|
134
199
|
});
|
|
135
200
|
|
|
136
|
-
process.stdout.write("Type Ctrl-D to exit, 'help' for help\n");
|
|
201
|
+
process.stdout.write("Type Ctrl-D to exit, 'help' for server commands, '/help' for local ones\n");
|
|
137
202
|
rl.setPrompt(`${cfg.env}> `);
|
|
138
203
|
rl.prompt();
|
|
139
204
|
|
|
@@ -143,6 +208,21 @@ export async function repl(cfg: EffectiveConfig, options: ShellOptions) {
|
|
|
143
208
|
rl.prompt();
|
|
144
209
|
return;
|
|
145
210
|
}
|
|
211
|
+
if (cmd.startsWith("/")) {
|
|
212
|
+
// Input is paused for the duration. readline does not await this handler, so without
|
|
213
|
+
// it a browser login -- which waits on a human -- would let lines typed meanwhile fire
|
|
214
|
+
// concurrently and run against the pre-login token, which is the state /login exists
|
|
215
|
+
// to get out of.
|
|
216
|
+
rl.pause();
|
|
217
|
+
try {
|
|
218
|
+
await handleReplCommand(cfg, cmd);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
process.stderr.write(`ERROR: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
221
|
+
}
|
|
222
|
+
rl.resume();
|
|
223
|
+
rl.prompt();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
146
226
|
try {
|
|
147
227
|
const authHeader = await resolveAuthHeaderFast(cfg);
|
|
148
228
|
await shellCmd(cfg, authHeader, cmd, Boolean(options.json));
|
package/src/lib/auth.ts
CHANGED
|
@@ -12,6 +12,32 @@ const AUTH_DIR = process.env.EDGE_CLI_AUTH_DIR
|
|
|
12
12
|
const AUTH_FILE = path.join(AUTH_DIR, "accounts.json");
|
|
13
13
|
const DEFAULT_DEVICE_NAME = "edge-cli";
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Value of the `scope` field sent at token exchange, so the server knows this token is for the
|
|
17
|
+
* shell rather than for an app session.
|
|
18
|
+
*
|
|
19
|
+
* Named EXCHANGE_SCOPE_* deliberately: "scope" already means something else in this file -- the
|
|
20
|
+
* product/env bucket of the local token store (`scopeKey`, `db.scopes`). These are unrelated, and
|
|
21
|
+
* the wire field keeps the OAuth-conventional name while the constant says which "scope" it is.
|
|
22
|
+
*
|
|
23
|
+
* `/api/v1/auth/exchange` is not a CLI endpoint -- it is the primary auth path for the mobile apps
|
|
24
|
+
* and the regulars web app too -- so the server cannot infer "this is the CLI" and cannot apply a
|
|
25
|
+
* stricter rule to everyone. This says so explicitly.
|
|
26
|
+
*
|
|
27
|
+
* Not derived from `deviceName`, which already reads "edge-cli": that is a display string for
|
|
28
|
+
* session lists, and quietly promoting cosmetic client input into an authorization input is how a
|
|
29
|
+
* bypass arrives unnoticed.
|
|
30
|
+
*
|
|
31
|
+
* Sent on **exchange only, never on refresh**. A refresh must re-derive the scope from the session
|
|
32
|
+
* the server persisted it on; honouring it from the request would let anyone escalate a non-shell
|
|
33
|
+
* session to a shell one by adding this field to a refresh call.
|
|
34
|
+
*
|
|
35
|
+
* Harmless against a server that predates the field: the API's ObjectMapper sets
|
|
36
|
+
* FAIL_ON_UNKNOWN_PROPERTIES=false, so an older deployment ignores it rather than rejecting the
|
|
37
|
+
* exchange.
|
|
38
|
+
*/
|
|
39
|
+
const EXCHANGE_SCOPE_SHELL = "shell";
|
|
40
|
+
|
|
15
41
|
type AuthAccount = {
|
|
16
42
|
accessToken: string;
|
|
17
43
|
refreshToken?: string;
|
|
@@ -163,7 +189,8 @@ async function authExchangeFirebaseToken(cfg: EffectiveConfig, firebaseToken: st
|
|
|
163
189
|
const resp = await requestTokenEndpoint(cfg, "exchange", {
|
|
164
190
|
firebaseToken,
|
|
165
191
|
deviceId: authDeviceId(),
|
|
166
|
-
deviceName: DEFAULT_DEVICE_NAME
|
|
192
|
+
deviceName: DEFAULT_DEVICE_NAME,
|
|
193
|
+
scope: EXCHANGE_SCOPE_SHELL
|
|
167
194
|
});
|
|
168
195
|
if (!resp?.accessToken) {
|
|
169
196
|
throw new Error("Token exchange did not return accessToken");
|
|
@@ -200,24 +227,25 @@ async function authLogoutRefreshToken(cfg: EffectiveConfig, refreshToken: string
|
|
|
200
227
|
* Exchanging it gets a first-party access token plus a refresh token (30 days by default), which
|
|
201
228
|
* resolveAccessToken already rotates automatically.
|
|
202
229
|
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
230
|
+
* There is NO fallback: a failed exchange fails the login. Storing the callback token as-is meant a
|
|
231
|
+
* server that answered authoritatively -- 401 "User doesn't exist" -- still reported
|
|
232
|
+
* "Successfully authenticated as ..." while storing a credential every API call rejects. See the
|
|
233
|
+
* commit that removed it for what the fallback used to cover and what dropping it costs.
|
|
206
234
|
*/
|
|
207
235
|
async function exchangeCallbackToken(cfg: EffectiveConfig, callbackToken: string): Promise<TokenResponse> {
|
|
208
236
|
try {
|
|
209
237
|
return await authExchangeFirebaseToken(cfg, callbackToken);
|
|
210
238
|
} catch (err) {
|
|
211
239
|
const reason = err instanceof Error ? err.message : String(err);
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
)
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
240
|
+
// The identity hint only fits a 401. On a network error or a 5xx, telling someone to check
|
|
241
|
+
// which account they used is misdirection -- the same species of misleading report this
|
|
242
|
+
// whole change removes.
|
|
243
|
+
const hint = reason.includes("(401)")
|
|
244
|
+
? `\nA 401 here usually means this account has no user record on ${cfg.product} -- ` +
|
|
245
|
+
`check you are signing in with the right identity for this product.`
|
|
246
|
+
: "";
|
|
247
|
+
throw new Error(`Login failed: could not exchange for a first-party token (${reason}).${hint}`,
|
|
248
|
+
{ cause: err });
|
|
221
249
|
}
|
|
222
250
|
}
|
|
223
251
|
|