wood-fired-tasks 2.0.4 → 2.0.6
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/CHANGELOG.md +56 -0
- package/README.md +5 -2
- package/dist/cli/auth/manual-pat.d.ts +99 -0
- package/dist/cli/auth/manual-pat.js +163 -0
- package/dist/cli/commands/login.d.ts +39 -0
- package/dist/cli/commands/login.js +99 -0
- package/dist/cli/commands/setup.d.ts +24 -81
- package/dist/cli/commands/setup.js +98 -199
- package/dist/cli/util/prompt.d.ts +2 -2
- package/dist/cli/util/prompt.js +30 -11
- package/docs/CLI.md +19 -6
- package/docs/SETUP.md +22 -11
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,62 @@ vulnerabilities, supply-chain pinning) are always called out under `Security`.
|
|
|
13
13
|
|
|
14
14
|
_No changes yet._
|
|
15
15
|
|
|
16
|
+
## [v2.0.6] - 2026-06-08
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- **`tasks login --token <pat>` — a manual-PAT login path.** `tasks login` was
|
|
20
|
+
device-flow only and **dead-ended on a remote non-`https` server**: the OAuth
|
|
21
|
+
device flow can't complete where Google rejects the non-`https` redirect URI,
|
|
22
|
+
and there was no way to supply a PAT instead. `login` now accepts `--token`
|
|
23
|
+
(validated against `GET /api/v1/me`, then persisted to the credentials file)
|
|
24
|
+
and, like `tasks setup`, applies the `canUseBrowserSso` gate — on a plain-`http`
|
|
25
|
+
non-localhost server it prints the `https`-required / how-to-mint-a-PAT
|
|
26
|
+
guidance and (on a TTY) prompts for one instead of launching a flow that can't
|
|
27
|
+
finish. The shared manual-PAT logic now lives in one module so `login` and
|
|
28
|
+
`setup` can't drift. (#857)
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- **`tasks setup --remote --token <pat>` now actually authenticates you.** The
|
|
32
|
+
non-interactive `--token` path wrote the PAT to an **orphaned cache file that
|
|
33
|
+
no code reads** and never to the credentials file, so it reported success while
|
|
34
|
+
leaving *both* the CLI ("Not authenticated. Run: tasks login") and the remote
|
|
35
|
+
MCP bridge unauthenticated — the likely root cause of "remote MCP shows 0
|
|
36
|
+
projects" reports. The path now validates the PAT against `GET /api/v1/me` and
|
|
37
|
+
persists it to the credentials file (the same writer the device flow uses); the
|
|
38
|
+
bridge resolves its bearer token from there at runtime. The dead `remote-token`
|
|
39
|
+
cache (`cachePat`/`patCachePath`) was removed — the credentials file is the
|
|
40
|
+
single source of truth. (#858)
|
|
41
|
+
- **The interactive "Paste a personal access token:" prompt no longer hangs on a
|
|
42
|
+
real terminal.** `promptSecret` enables TTY raw mode to suppress echo, which
|
|
43
|
+
disables CR→LF translation, so the Enter key delivers a bare `\r` (0x0D) — but
|
|
44
|
+
the line reader only terminated on `\n`, so the read blocked forever (pasted
|
|
45
|
+
PAT buffered, Enter never recognized). Hit on Windows PowerShell during
|
|
46
|
+
`tasks setup --remote` manual-PAT entry; platform-independent. The reader now
|
|
47
|
+
treats `\r`, `\n`, or `\r\n` as the line terminator. (#856)
|
|
48
|
+
|
|
49
|
+
### Security
|
|
50
|
+
- The `--remote --token` PAT is now stored only in the `0600` credentials file
|
|
51
|
+
and is still **never** embedded in `~/.claude.json` (the remote MCP entry stays
|
|
52
|
+
URL-only, #810). The removed `remote-token` cache eliminates a second on-disk
|
|
53
|
+
copy of the secret.
|
|
54
|
+
|
|
55
|
+
## [v2.0.5] - 2026-06-08
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
- **`tasks setup` → Remote now tells you the truth about what a server needs for
|
|
59
|
+
browser login.** When the server reports OIDC ready but you entered a
|
|
60
|
+
**plain-http, non-localhost URL**, browser/device login via Google SSO can
|
|
61
|
+
never complete — identity providers reject non-`https` OAuth redirect URIs
|
|
62
|
+
except for `localhost`. Previously setup would launch the device flow anyway
|
|
63
|
+
and the verification page dead-ended at the IdP callback ("unable to connect").
|
|
64
|
+
Setup now detects this up front and explains it: it tells you to either re-run
|
|
65
|
+
with an **https** URL (front the server with a TLS reverse proxy / real domain
|
|
66
|
+
so Google SSO completes) or **paste a personal access token**, and prints the
|
|
67
|
+
exact on-host command to mint one (`tasks db mint-token --user <email-or-id>`),
|
|
68
|
+
then drops straight into manual-PAT entry. `https` and `http://localhost`
|
|
69
|
+
servers are unaffected and complete browser login as before. New exported
|
|
70
|
+
helper `canUseBrowserSso()`.
|
|
71
|
+
|
|
16
72
|
## [v2.0.4] - 2026-06-08
|
|
17
73
|
|
|
18
74
|
### Fixed
|
package/README.md
CHANGED
|
@@ -119,8 +119,11 @@ wood-fired-tasks self-update # npm i -g wood-fired-tasks@latest (no sudo)
|
|
|
119
119
|
wood-fired-tasks setup --remote https://tasks.example.com --token wft_pat_…
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
-
This writes a `wood-fired-tasks-remote` MCP entry (proxying every tool
|
|
123
|
-
REST API) and
|
|
122
|
+
This writes a URL-only `wood-fired-tasks-remote` MCP entry (proxying every tool
|
|
123
|
+
to the REST API) and persists the validated PAT to the CLI credentials file —
|
|
124
|
+
the same file `tasks login` writes; the bridge reads its bearer token from there
|
|
125
|
+
at runtime (the PAT is never stored in `~/.claude.json`). Omit `--token` to run
|
|
126
|
+
the interactive device-flow / manual-PAT onboarding instead. For a full
|
|
124
127
|
Windows/Linux/macOS fleet on one on-prem server, see
|
|
125
128
|
[Multi-OS client fleet](docs/SETUP.md#multi-os-client-fleet-one-shared-on-prem-server).
|
|
126
129
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type PromptIO } from '../util/prompt.js';
|
|
2
|
+
/**
|
|
3
|
+
* Whether the browser/device login (Google SSO) can actually COMPLETE against
|
|
4
|
+
* `baseUrl` (#835).
|
|
5
|
+
*
|
|
6
|
+
* The whole OIDC dance — the verification page AND the IdP's OAuth callback —
|
|
7
|
+
* happens at the server's origin, and identity providers (Google especially)
|
|
8
|
+
* reject non-`https` OAuth redirect URIs *except* for `localhost`/`127.0.0.1`.
|
|
9
|
+
* So a server reached over plain `http` at a non-localhost address can report
|
|
10
|
+
* `oidc: 'ready'` yet still be unable to finish browser login: the user's
|
|
11
|
+
* browser gets bounced to an `http://…/auth/callback` the IdP won't honor. We
|
|
12
|
+
* detect that up front so onboarding can tell the user the truth (need https,
|
|
13
|
+
* or use a PAT) instead of opening a URL that dead-ends.
|
|
14
|
+
*
|
|
15
|
+
* Returns true for any `https` URL and for `http://localhost` / `127.0.0.1` /
|
|
16
|
+
* `[::1]`; false for plain-http non-loopback hosts and unparseable input.
|
|
17
|
+
*/
|
|
18
|
+
export declare function canUseBrowserSso(baseUrl: string): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* The advisory block shown when browser SSO can't complete against `baseUrl`
|
|
21
|
+
* (plain-http non-localhost): explain the https requirement and how to mint a
|
|
22
|
+
* PAT on the server host. Returned as discrete lines so each caller logs them
|
|
23
|
+
* through its own sink (setup → `log()`, login → stderr / JSON). Shared so the
|
|
24
|
+
* two commands print identical guidance (#857).
|
|
25
|
+
*/
|
|
26
|
+
export declare function browserSsoGuidance(baseUrl: string): string[];
|
|
27
|
+
/**
|
|
28
|
+
* The minimal identity envelope `GET /api/v1/me` returns (task #809). Mirrors
|
|
29
|
+
* the fields {@link writeCredentials} needs so a manually-pasted PAT lands in
|
|
30
|
+
* the SAME credentials file the device flow writes — the bridge then resolves
|
|
31
|
+
* its bearer token from there at runtime (URL-only claude.json entry, #810).
|
|
32
|
+
*/
|
|
33
|
+
export interface ManualPatIdentity {
|
|
34
|
+
id: number;
|
|
35
|
+
displayName: string;
|
|
36
|
+
email: string | null;
|
|
37
|
+
/** Best-effort token rowid; defaults to 1 when the server omits it. */
|
|
38
|
+
tokenId?: number;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Outcome of persisting a manually-supplied PAT (task #809).
|
|
42
|
+
* - `{ ok: true, identity }` — the PAT validated and credentials were written.
|
|
43
|
+
* - `{ ok: false, reason }` — the PAT was rejected / unreachable; `reason`
|
|
44
|
+
* is surfaced to the user and NOTHING is persisted.
|
|
45
|
+
*/
|
|
46
|
+
export type ManualPatPersistResult = {
|
|
47
|
+
ok: true;
|
|
48
|
+
identity: ManualPatIdentity;
|
|
49
|
+
} | {
|
|
50
|
+
ok: false;
|
|
51
|
+
reason: string;
|
|
52
|
+
};
|
|
53
|
+
/** Injectable manual-PAT persistence seam so tests drive it without a server. */
|
|
54
|
+
export type ManualPatPersist = (baseUrl: string, token: string) => Promise<ManualPatPersistResult>;
|
|
55
|
+
/**
|
|
56
|
+
* Default manual-PAT persistence (task #809).
|
|
57
|
+
*
|
|
58
|
+
* Validate the pasted PAT against `GET <baseUrl>/api/v1/me` (the same identity
|
|
59
|
+
* envelope `tasks whoami` reads), then persist it through {@link writeCredentials}
|
|
60
|
+
* — the SAME credentials writer the device flow uses. This is the only place the
|
|
61
|
+
* manual PAT lands; the claude.json entry stays URL-only (#810) and the bridge
|
|
62
|
+
* resolves the bearer token from this credentials file at runtime, so the secret
|
|
63
|
+
* is never embedded in claude.json.
|
|
64
|
+
*
|
|
65
|
+
* A non-2xx / network failure returns `{ ok: false, reason }` and writes
|
|
66
|
+
* NOTHING — the caller reports the reason and exits without a half-configured
|
|
67
|
+
* install.
|
|
68
|
+
*/
|
|
69
|
+
export declare function persistManualPat(baseUrl: string, token: string): Promise<ManualPatPersistResult>;
|
|
70
|
+
/** Inputs to {@link resolveManualPatToken}. */
|
|
71
|
+
export interface ResolveManualPatTokenArgs {
|
|
72
|
+
/** Explicit PAT (e.g. `--token <pat>`); wins unconditionally when non-empty. */
|
|
73
|
+
token?: string;
|
|
74
|
+
/** Injectable prompt IO forwarded to {@link promptSecret} (tests/no-TTY). */
|
|
75
|
+
promptIO?: PromptIO;
|
|
76
|
+
/** TTY predicate (defaults to {@link shouldPrompt}). */
|
|
77
|
+
isInteractive?: () => boolean;
|
|
78
|
+
/** Injectable secret prompt (defaults to {@link promptSecret}) for tests. */
|
|
79
|
+
promptSecretFn?: (prompt: string, io?: PromptIO) => Promise<string>;
|
|
80
|
+
/** Prompt label shown before reading the secret. */
|
|
81
|
+
promptLabel?: string;
|
|
82
|
+
/**
|
|
83
|
+
* Optional env var consulted as the LAST resort on a non-TTY (e.g.
|
|
84
|
+
* `'WFT_API_KEY'` for `setup`, which the remote bridge also reads). Omit to
|
|
85
|
+
* disable the env fallback (e.g. `tasks login`).
|
|
86
|
+
*/
|
|
87
|
+
envVar?: string;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Resolve a manual PAT from, in precedence order:
|
|
91
|
+
* 1. an explicit `token` (the `--token` flag),
|
|
92
|
+
* 2. an interactive `promptSecret` (only on a TTY — never echoed),
|
|
93
|
+
* 3. an optional `envVar` fallback (non-TTY automation).
|
|
94
|
+
*
|
|
95
|
+
* Returns `undefined` when none yields a non-empty value, so the caller can
|
|
96
|
+
* print actionable guidance instead of hanging on a prompt with no TTY. Shared
|
|
97
|
+
* by `setup` and `login` so their PAT-sourcing rules stay identical (#857).
|
|
98
|
+
*/
|
|
99
|
+
export declare function resolveManualPatToken(args: ResolveManualPatTokenArgs): Promise<string | undefined>;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared manual-PAT onboarding primitives used by BOTH `tasks setup --remote`
|
|
3
|
+
* and `tasks login` (tasks #857/#858).
|
|
4
|
+
*
|
|
5
|
+
* History: `setup` grew a full manual-PAT path (browser-SSO gate → validate the
|
|
6
|
+
* pasted PAT against `GET /api/v1/me` → persist via {@link writeCredentials}),
|
|
7
|
+
* but `login` shipped device-flow-only and `setup --remote --token` wrote the
|
|
8
|
+
* PAT to an orphaned cache file that NO code reads. Both bugs trace to the same
|
|
9
|
+
* logic living inline in setup.ts where login couldn't reuse it. This module is
|
|
10
|
+
* the single source of truth so the two commands can't drift again.
|
|
11
|
+
*
|
|
12
|
+
* Nothing here writes to `claude.json`; the credentials TOML (owned by
|
|
13
|
+
* {@link writeCredentials}) is the ONLY place a manual PAT lands. The CLI and the
|
|
14
|
+
* remote MCP bridge both resolve their bearer token from that file at runtime.
|
|
15
|
+
*/
|
|
16
|
+
import { writeCredentials } from './credentials.js';
|
|
17
|
+
import { promptSecret } from '../util/prompt.js';
|
|
18
|
+
import { shouldPrompt } from '../prompts/interactive.js';
|
|
19
|
+
/**
|
|
20
|
+
* Whether the browser/device login (Google SSO) can actually COMPLETE against
|
|
21
|
+
* `baseUrl` (#835).
|
|
22
|
+
*
|
|
23
|
+
* The whole OIDC dance — the verification page AND the IdP's OAuth callback —
|
|
24
|
+
* happens at the server's origin, and identity providers (Google especially)
|
|
25
|
+
* reject non-`https` OAuth redirect URIs *except* for `localhost`/`127.0.0.1`.
|
|
26
|
+
* So a server reached over plain `http` at a non-localhost address can report
|
|
27
|
+
* `oidc: 'ready'` yet still be unable to finish browser login: the user's
|
|
28
|
+
* browser gets bounced to an `http://…/auth/callback` the IdP won't honor. We
|
|
29
|
+
* detect that up front so onboarding can tell the user the truth (need https,
|
|
30
|
+
* or use a PAT) instead of opening a URL that dead-ends.
|
|
31
|
+
*
|
|
32
|
+
* Returns true for any `https` URL and for `http://localhost` / `127.0.0.1` /
|
|
33
|
+
* `[::1]`; false for plain-http non-loopback hosts and unparseable input.
|
|
34
|
+
*/
|
|
35
|
+
export function canUseBrowserSso(baseUrl) {
|
|
36
|
+
let url;
|
|
37
|
+
try {
|
|
38
|
+
url = new URL(baseUrl);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
if (url.protocol === 'https:')
|
|
44
|
+
return true;
|
|
45
|
+
if (url.protocol !== 'http:')
|
|
46
|
+
return false;
|
|
47
|
+
const host = url.hostname.toLowerCase();
|
|
48
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The advisory block shown when browser SSO can't complete against `baseUrl`
|
|
52
|
+
* (plain-http non-localhost): explain the https requirement and how to mint a
|
|
53
|
+
* PAT on the server host. Returned as discrete lines so each caller logs them
|
|
54
|
+
* through its own sink (setup → `log()`, login → stderr / JSON). Shared so the
|
|
55
|
+
* two commands print identical guidance (#857).
|
|
56
|
+
*/
|
|
57
|
+
export function browserSsoGuidance(baseUrl) {
|
|
58
|
+
return [
|
|
59
|
+
'',
|
|
60
|
+
`"${baseUrl}" is plain http at a non-localhost address.`,
|
|
61
|
+
'Browser login via Google SSO requires an https URL — identity providers',
|
|
62
|
+
'reject non-https OAuth redirect URIs except for localhost — so the device',
|
|
63
|
+
'flow cannot complete against this server. To finish, either:',
|
|
64
|
+
' • re-run with an https URL for this server (e.g. front it with a TLS',
|
|
65
|
+
' reverse proxy / real domain so Google SSO completes), or',
|
|
66
|
+
' • paste a personal access token now.',
|
|
67
|
+
'',
|
|
68
|
+
'To mint a PAT, run this ON THE SERVER HOST:',
|
|
69
|
+
' tasks db mint-token --user <your-email-or-user-id>',
|
|
70
|
+
'(or create one from your account page once logged in via the browser).',
|
|
71
|
+
'',
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Default manual-PAT persistence (task #809).
|
|
76
|
+
*
|
|
77
|
+
* Validate the pasted PAT against `GET <baseUrl>/api/v1/me` (the same identity
|
|
78
|
+
* envelope `tasks whoami` reads), then persist it through {@link writeCredentials}
|
|
79
|
+
* — the SAME credentials writer the device flow uses. This is the only place the
|
|
80
|
+
* manual PAT lands; the claude.json entry stays URL-only (#810) and the bridge
|
|
81
|
+
* resolves the bearer token from this credentials file at runtime, so the secret
|
|
82
|
+
* is never embedded in claude.json.
|
|
83
|
+
*
|
|
84
|
+
* A non-2xx / network failure returns `{ ok: false, reason }` and writes
|
|
85
|
+
* NOTHING — the caller reports the reason and exits without a half-configured
|
|
86
|
+
* install.
|
|
87
|
+
*/
|
|
88
|
+
export async function persistManualPat(baseUrl, token) {
|
|
89
|
+
const meUrl = new URL('/api/v1/me', baseUrl).toString();
|
|
90
|
+
let response;
|
|
91
|
+
try {
|
|
92
|
+
response = await fetch(meUrl, { headers: { Authorization: `Bearer ${token}` } });
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
96
|
+
return { ok: false, reason: `could not reach ${meUrl}: ${message}` };
|
|
97
|
+
}
|
|
98
|
+
if (response.status === 401) {
|
|
99
|
+
return { ok: false, reason: 'the personal access token was rejected (HTTP 401)' };
|
|
100
|
+
}
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
return { ok: false, reason: `${meUrl} returned HTTP ${response.status}` };
|
|
103
|
+
}
|
|
104
|
+
let body;
|
|
105
|
+
try {
|
|
106
|
+
body = (await response.json());
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return { ok: false, reason: `${meUrl} returned a non-JSON body` };
|
|
110
|
+
}
|
|
111
|
+
if (body === null || typeof body.id !== 'number' || typeof body.displayName !== 'string') {
|
|
112
|
+
return { ok: false, reason: `${meUrl} did not return a usable identity` };
|
|
113
|
+
}
|
|
114
|
+
const email = typeof body.email === 'string' ? body.email : null;
|
|
115
|
+
const identity = { id: body.id, displayName: body.displayName, email };
|
|
116
|
+
// Persist through the SAME credentials writer the device flow uses. The
|
|
117
|
+
// server's /me envelope does not carry the token rowid, so default token_id
|
|
118
|
+
// to 1 (a positive int, satisfying the credentials schema); `whoami`'s
|
|
119
|
+
// best-effort token enrichment degrades gracefully when it can't match it.
|
|
120
|
+
try {
|
|
121
|
+
writeCredentials({
|
|
122
|
+
active: {
|
|
123
|
+
token,
|
|
124
|
+
token_id: 1,
|
|
125
|
+
server: baseUrl,
|
|
126
|
+
user_id: identity.id,
|
|
127
|
+
display_name: identity.displayName,
|
|
128
|
+
email: identity.email,
|
|
129
|
+
logged_in_at: new Date().toISOString(),
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
135
|
+
return { ok: false, reason: `failed to write credentials file: ${message}` };
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, identity };
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Resolve a manual PAT from, in precedence order:
|
|
141
|
+
* 1. an explicit `token` (the `--token` flag),
|
|
142
|
+
* 2. an interactive `promptSecret` (only on a TTY — never echoed),
|
|
143
|
+
* 3. an optional `envVar` fallback (non-TTY automation).
|
|
144
|
+
*
|
|
145
|
+
* Returns `undefined` when none yields a non-empty value, so the caller can
|
|
146
|
+
* print actionable guidance instead of hanging on a prompt with no TTY. Shared
|
|
147
|
+
* by `setup` and `login` so their PAT-sourcing rules stay identical (#857).
|
|
148
|
+
*/
|
|
149
|
+
export async function resolveManualPatToken(args) {
|
|
150
|
+
let token = args.token;
|
|
151
|
+
if ((typeof token !== 'string' || token.length === 0) && (args.isInteractive ?? shouldPrompt)()) {
|
|
152
|
+
const ask = args.promptSecretFn ?? promptSecret;
|
|
153
|
+
token = await ask(args.promptLabel ?? 'Paste a personal access token: ', args.promptIO);
|
|
154
|
+
}
|
|
155
|
+
if ((typeof token !== 'string' || token.length === 0) && args.envVar) {
|
|
156
|
+
const envToken = process.env[args.envVar];
|
|
157
|
+
if (typeof envToken === 'string' && envToken.length > 0) {
|
|
158
|
+
token = envToken;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return typeof token === 'string' && token.length > 0 ? token : undefined;
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=manual-pat.js.map
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { type DeviceTokenSuccess } from '../auth/device-flow.js';
|
|
3
|
+
import { type ManualPatPersist } from '../auth/manual-pat.js';
|
|
4
|
+
import type { PromptIO } from '../util/prompt.js';
|
|
3
5
|
/**
|
|
4
6
|
* Inputs to {@link runDeviceLogin}. All values are already resolved/validated by
|
|
5
7
|
* the caller (the `login` command and, in Phase 30 Plan 07+, the setup wizard).
|
|
@@ -39,4 +41,41 @@ export type RunDeviceLoginResult = {
|
|
|
39
41
|
* emitting the appropriate error output, and the caller decides the exit code.
|
|
40
42
|
*/
|
|
41
43
|
export declare function runDeviceLogin(args: RunDeviceLoginArgs): Promise<RunDeviceLoginResult>;
|
|
44
|
+
/**
|
|
45
|
+
* Inputs to {@link runManualPatLogin} (task #857). Mirrors {@link RunDeviceLoginArgs}
|
|
46
|
+
* for the parts the command shares (baseUrl, isJson).
|
|
47
|
+
*/
|
|
48
|
+
export interface RunManualPatLoginArgs {
|
|
49
|
+
/** Validated server base URL. */
|
|
50
|
+
baseUrl: string;
|
|
51
|
+
/** Explicit PAT (`--token <pat>`); when absent, prompt on a TTY. */
|
|
52
|
+
token?: string;
|
|
53
|
+
/** When true, emit newline-separated JSON envelopes on stdout (vs text on stderr). */
|
|
54
|
+
isJson: boolean;
|
|
55
|
+
/** Injectable prompt IO (tests). Defaults to process.stdin/stdout. */
|
|
56
|
+
promptIO?: PromptIO;
|
|
57
|
+
/** Injectable TTY predicate (tests). Defaults to the real shouldPrompt. */
|
|
58
|
+
isInteractive?: () => boolean;
|
|
59
|
+
/** Injectable persistence seam (tests). Defaults to {@link persistManualPat}. */
|
|
60
|
+
manualPatPersist?: ManualPatPersist;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Manual-PAT login core (task #857): the parity-with-`tasks setup` path that
|
|
64
|
+
* lets `tasks login` finish on a server where browser SSO can't complete (a
|
|
65
|
+
* plain-http / LAN-IP server the IdP rejects), or whenever the user supplies a
|
|
66
|
+
* PAT directly.
|
|
67
|
+
*
|
|
68
|
+
* Behavior:
|
|
69
|
+
* 1. If browser SSO can't complete against `baseUrl` AND no `--token` was
|
|
70
|
+
* supplied, print the same https-required / mint-a-PAT guidance `setup`
|
|
71
|
+
* shows (shared via {@link browserSsoGuidance}).
|
|
72
|
+
* 2. Resolve the PAT: `--token` flag → interactive `promptSecret` (TTY only).
|
|
73
|
+
* 3. Validate + persist via {@link persistManualPat} — the SAME credentials
|
|
74
|
+
* writer the device flow uses, so `tasks whoami` / the API client / the MCP
|
|
75
|
+
* bridge all see the credential afterward.
|
|
76
|
+
*
|
|
77
|
+
* Security invariant (matches the device path): the PAT is NEVER written to
|
|
78
|
+
* stdout/stderr — the credentials file is its only resting place.
|
|
79
|
+
*/
|
|
80
|
+
export declare function runManualPatLogin(args: RunManualPatLoginArgs): Promise<RunDeviceLoginResult>;
|
|
42
81
|
export declare const loginCommand: Command;
|
|
@@ -31,6 +31,7 @@ import { env } from '../config/env.js';
|
|
|
31
31
|
import { writeCredentials } from '../auth/credentials.js';
|
|
32
32
|
import { openBrowser } from '../auth/browser-open.js';
|
|
33
33
|
import { requestDeviceCode, pollForToken } from '../auth/device-flow.js';
|
|
34
|
+
import { canUseBrowserSso, browserSsoGuidance, persistManualPat, resolveManualPatToken, } from '../auth/manual-pat.js';
|
|
34
35
|
/** Emit one newline-separated JSON envelope on stdout (used in --json mode). */
|
|
35
36
|
function emitJsonEvent(event) {
|
|
36
37
|
process.stdout.write(JSON.stringify(event) + '\n');
|
|
@@ -185,9 +186,83 @@ export async function runDeviceLogin(args) {
|
|
|
185
186
|
}
|
|
186
187
|
return { ok: true, user: response.user };
|
|
187
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Manual-PAT login core (task #857): the parity-with-`tasks setup` path that
|
|
191
|
+
* lets `tasks login` finish on a server where browser SSO can't complete (a
|
|
192
|
+
* plain-http / LAN-IP server the IdP rejects), or whenever the user supplies a
|
|
193
|
+
* PAT directly.
|
|
194
|
+
*
|
|
195
|
+
* Behavior:
|
|
196
|
+
* 1. If browser SSO can't complete against `baseUrl` AND no `--token` was
|
|
197
|
+
* supplied, print the same https-required / mint-a-PAT guidance `setup`
|
|
198
|
+
* shows (shared via {@link browserSsoGuidance}).
|
|
199
|
+
* 2. Resolve the PAT: `--token` flag → interactive `promptSecret` (TTY only).
|
|
200
|
+
* 3. Validate + persist via {@link persistManualPat} — the SAME credentials
|
|
201
|
+
* writer the device flow uses, so `tasks whoami` / the API client / the MCP
|
|
202
|
+
* bridge all see the credential afterward.
|
|
203
|
+
*
|
|
204
|
+
* Security invariant (matches the device path): the PAT is NEVER written to
|
|
205
|
+
* stdout/stderr — the credentials file is its only resting place.
|
|
206
|
+
*/
|
|
207
|
+
export async function runManualPatLogin(args) {
|
|
208
|
+
const { baseUrl, isJson } = args;
|
|
209
|
+
const hasToken = typeof args.token === 'string' && args.token.length > 0;
|
|
210
|
+
// Explain the https requirement up front when the user reached for `login`
|
|
211
|
+
// on a server browser SSO can't complete against and gave us no PAT to use.
|
|
212
|
+
if (!hasToken && !canUseBrowserSso(baseUrl) && !isJson) {
|
|
213
|
+
for (const line of browserSsoGuidance(baseUrl)) {
|
|
214
|
+
process.stderr.write(`${line}\n`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const token = await resolveManualPatToken({
|
|
218
|
+
...(args.token !== undefined && { token: args.token }),
|
|
219
|
+
...(args.promptIO !== undefined && { promptIO: args.promptIO }),
|
|
220
|
+
...(args.isInteractive !== undefined && { isInteractive: args.isInteractive }),
|
|
221
|
+
promptLabel: 'Paste a personal access token: ',
|
|
222
|
+
});
|
|
223
|
+
if (token === undefined) {
|
|
224
|
+
const message = 'No personal access token supplied. Re-run with --token <pat> to finish login.';
|
|
225
|
+
if (isJson) {
|
|
226
|
+
emitJsonEvent({ event: 'failed', error: 'no_token', message });
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
process.stderr.write(`${message}\n`);
|
|
230
|
+
}
|
|
231
|
+
return { ok: false };
|
|
232
|
+
}
|
|
233
|
+
const persist = args.manualPatPersist ?? persistManualPat;
|
|
234
|
+
const persisted = await persist(baseUrl, token);
|
|
235
|
+
if (!persisted.ok) {
|
|
236
|
+
const message = `Could not store the personal access token: ${persisted.reason}.`;
|
|
237
|
+
if (isJson) {
|
|
238
|
+
emitJsonEvent({ event: 'failed', error: 'pat_rejected', message });
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
process.stderr.write(`${message}\n`);
|
|
242
|
+
}
|
|
243
|
+
return { ok: false };
|
|
244
|
+
}
|
|
245
|
+
const user = {
|
|
246
|
+
id: persisted.identity.id,
|
|
247
|
+
displayName: persisted.identity.displayName,
|
|
248
|
+
email: persisted.identity.email,
|
|
249
|
+
};
|
|
250
|
+
if (isJson) {
|
|
251
|
+
emitJsonEvent({
|
|
252
|
+
event: 'logged_in',
|
|
253
|
+
user,
|
|
254
|
+
token_id: persisted.identity.tokenId ?? null,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
process.stderr.write(`Logged in as ${persisted.identity.displayName}\n`);
|
|
259
|
+
}
|
|
260
|
+
return { ok: true, user };
|
|
261
|
+
}
|
|
188
262
|
export const loginCommand = new Command('login')
|
|
189
263
|
.description('Authenticate with the WFT server via OAuth device flow')
|
|
190
264
|
.option('--token-name <name>', 'Name for the minted PAT (currently advisory; reserved for v1.7 explicit naming)')
|
|
265
|
+
.option('--token <pat>', 'Authenticate with a personal access token instead of the browser device flow (required for remote non-https servers where Google SSO cannot complete). The PAT is validated against the server and stored in the credentials file.')
|
|
191
266
|
.option('--no-browser', 'Skip auto-opening the verification URL in a browser')
|
|
192
267
|
.option('--server <url>', 'Override API_BASE_URL for this invocation (stored in credentials file)')
|
|
193
268
|
.action(async (opts) => {
|
|
@@ -213,6 +288,30 @@ export const loginCommand = new Command('login')
|
|
|
213
288
|
process.exitCode = 1;
|
|
214
289
|
return;
|
|
215
290
|
}
|
|
291
|
+
// `--token` may bind to EITHER the login command (when written as
|
|
292
|
+
// `tasks login --token <pat>`) or the root program's Bearer-auth flag (when
|
|
293
|
+
// written as `tasks --token <pat> login`). Accept both so the manual-PAT
|
|
294
|
+
// login path works regardless of where Commander attached it.
|
|
295
|
+
const token = typeof opts.token === 'string' && opts.token.length > 0
|
|
296
|
+
? opts.token
|
|
297
|
+
: globalOpts['token'];
|
|
298
|
+
const hasToken = typeof token === 'string' && token.length > 0;
|
|
299
|
+
// 2. Choose the login path (task #857):
|
|
300
|
+
// - manual-PAT when the user supplied a PAT, OR when browser SSO can't
|
|
301
|
+
// complete against this server (plain-http non-localhost — Google
|
|
302
|
+
// rejects the non-https OAuth redirect, so the device flow dead-ends).
|
|
303
|
+
// - device flow otherwise (the default for https / localhost servers).
|
|
304
|
+
if (hasToken || !canUseBrowserSso(baseUrl)) {
|
|
305
|
+
const result = await runManualPatLogin({
|
|
306
|
+
baseUrl,
|
|
307
|
+
...(token !== undefined && { token }),
|
|
308
|
+
isJson,
|
|
309
|
+
});
|
|
310
|
+
if (!result.ok) {
|
|
311
|
+
process.exitCode = 1;
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
216
315
|
const clientId = process.env['OIDC_CLIENT_ID'] ?? 'wft-cli';
|
|
217
316
|
const hostname = os.hostname();
|
|
218
317
|
// Delegate to the shared device-login core. Commander generates
|
|
@@ -2,6 +2,8 @@ import { Command } from 'commander';
|
|
|
2
2
|
import { type ClaudeMcpServerEntry } from '../../setup/claude-json.js';
|
|
3
3
|
import { type PromptIO } from '../util/prompt.js';
|
|
4
4
|
import { runDeviceLogin } from './login.js';
|
|
5
|
+
import { canUseBrowserSso, browserSsoGuidance, persistManualPat, type ManualPatIdentity, type ManualPatPersist, type ManualPatPersistResult } from '../auth/manual-pat.js';
|
|
6
|
+
export { canUseBrowserSso, browserSsoGuidance, persistManualPat, type ManualPatIdentity, type ManualPatPersist, type ManualPatPersistResult, };
|
|
5
7
|
/**
|
|
6
8
|
* Resolve the local MCP server entry point. Prefer the built
|
|
7
9
|
* `dist/mcp/index.js` under the package root (deterministic for published
|
|
@@ -37,28 +39,6 @@ export declare function resolveRemoteMcpEntryPoint(): string;
|
|
|
37
39
|
export declare function buildRemoteMcpEntry(apiUrl: string): ClaudeMcpServerEntry;
|
|
38
40
|
/** Default destination for copied skills. */
|
|
39
41
|
export declare function commandsDestDir(home?: string): string;
|
|
40
|
-
/**
|
|
41
|
-
* Absolute path of the cached PAT file under the OS CONFIG dir (task #738).
|
|
42
|
-
*
|
|
43
|
-
* The PAT is operator configuration — NOT persistent application state — so it
|
|
44
|
-
* lives under `configDir` (env-paths, OS-correct), NEVER the data dir where the
|
|
45
|
-
* SQLite DB lives. `configDir` is injectable so tests can sandbox it into a
|
|
46
|
-
* temp directory.
|
|
47
|
-
*/
|
|
48
|
-
export declare function patCachePath(configDir?: string): string;
|
|
49
|
-
export interface CachePatResult {
|
|
50
|
-
/** Absolute path the PAT was written to. */
|
|
51
|
-
path: string;
|
|
52
|
-
/** True when the file content changed this run (idempotency signal). */
|
|
53
|
-
changed: boolean;
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Cache the remote PAT to a file under the CONFIG dir, idempotently. On POSIX
|
|
57
|
-
* the file is created/tightened to 0600 (owner read/write only) so the secret
|
|
58
|
-
* is not world-readable; on Windows the mode arg is a best-effort no-op (NTFS
|
|
59
|
-
* ACLs already restrict the per-user config dir).
|
|
60
|
-
*/
|
|
61
|
-
export declare function cachePat(token: string, configDir?: string): CachePatResult;
|
|
62
42
|
export interface CopySkillsResult {
|
|
63
43
|
sourceDir: string;
|
|
64
44
|
destDir: string;
|
|
@@ -219,17 +199,20 @@ export interface RunSetupOptions {
|
|
|
219
199
|
npmRunner?: (cmd: string, args: string[]) => void;
|
|
220
200
|
log?: (line: string) => void;
|
|
221
201
|
/**
|
|
222
|
-
* Remote REST API base URL (task #738).
|
|
223
|
-
*
|
|
224
|
-
* `
|
|
202
|
+
* Remote REST API base URL (task #738). Routes `tasks setup` to the remote
|
|
203
|
+
* onboarding path (probe → device-flow / manual-PAT) via
|
|
204
|
+
* {@link runSetupInteractive}; the URL-only `wood-fired-tasks-remote` bridge
|
|
205
|
+
* entry is written by {@link writeRemoteMcpEntryOnly}. NOT consumed by
|
|
206
|
+
* {@link runSetup}, which is the LOCAL install only.
|
|
225
207
|
*/
|
|
226
208
|
remote?: string;
|
|
227
|
-
/** Remote PAT (task #738). Cached under the CONFIG dir; never the data dir. */
|
|
228
|
-
token?: string;
|
|
229
209
|
/**
|
|
230
|
-
*
|
|
231
|
-
*
|
|
210
|
+
* Remote PAT (`--token <pat>`). Validated against `GET /api/v1/me` and
|
|
211
|
+
* persisted to the credentials file via {@link persistManualPat} (#858); it is
|
|
212
|
+
* NEVER written into claude.json. NOT consumed by {@link runSetup}.
|
|
232
213
|
*/
|
|
214
|
+
token?: string;
|
|
215
|
+
/** Override the OS CONFIG dir (testing). Reserved for callers that need it. */
|
|
233
216
|
configDir?: string;
|
|
234
217
|
}
|
|
235
218
|
export interface RunSetupResult {
|
|
@@ -242,24 +225,27 @@ export interface RunSetupResult {
|
|
|
242
225
|
skills: CopySkillsResult;
|
|
243
226
|
agents: CopyAgentsResult;
|
|
244
227
|
npmPrefix?: FixNpmPrefixResult;
|
|
245
|
-
/** Set when a PAT was cached (i.e. `remote` + `token` provided). */
|
|
246
|
-
patCache?: CachePatResult;
|
|
247
228
|
}
|
|
248
229
|
/**
|
|
249
|
-
* Pure-ish setup action. Resolves all paths from `home` so tests can
|
|
250
|
-
* with a temp HOME and never touch the real ~/.claude.json or ~/.claude/.
|
|
230
|
+
* Pure-ish LOCAL setup action. Resolves all paths from `home` so tests can
|
|
231
|
+
* sandbox with a temp HOME and never touch the real ~/.claude.json or ~/.claude/.
|
|
232
|
+
*
|
|
233
|
+
* Remote onboarding is NOT handled here — it requires an async server round-trip
|
|
234
|
+
* to validate + persist a credential (#858). {@link runSetupInteractive} routes
|
|
235
|
+
* `--remote` to {@link runRemoteOnboarding} / the manual-PAT path, which write
|
|
236
|
+
* the URL-only bridge entry via {@link writeRemoteMcpEntryOnly}.
|
|
251
237
|
*/
|
|
252
238
|
export declare function runSetup(options?: RunSetupOptions): RunSetupResult;
|
|
253
239
|
/**
|
|
254
240
|
* Write ONLY the URL-only remote MCP bridge entry into ~/.claude.json (and copy
|
|
255
241
|
* skills/agents), WITHOUT requiring or caching a PAT (task #808).
|
|
256
242
|
*
|
|
257
|
-
* This is the
|
|
258
|
-
*
|
|
259
|
-
* it via the credentials writer, there is no token to embed
|
|
243
|
+
* This is the shared remote-entry writer for BOTH onboarding paths: after
|
|
244
|
+
* {@link runDeviceLogin} (#806) or the manual-PAT path has provisioned the PAT
|
|
245
|
+
* and persisted it via the credentials writer, there is no token to embed —
|
|
260
246
|
* the claude.json entry must be URL-only (#810). So this helper mirrors
|
|
261
|
-
* runSetup's claude.json/skills/agents work but
|
|
262
|
-
*
|
|
247
|
+
* runSetup's claude.json/skills/agents work but writes the REMOTE bridge entry
|
|
248
|
+
* and persists NO token of its own (the credentials file is the sole owner).
|
|
263
249
|
*
|
|
264
250
|
* The written entry is exactly `buildRemoteMcpEntry(apiUrl)`: a stdio bridge
|
|
265
251
|
* carrying only `WFT_API_URL`. The bridge resolves its bearer token at runtime
|
|
@@ -334,49 +320,6 @@ export declare function probeOidcState(baseUrl: string): Promise<OidcProbeResult
|
|
|
334
320
|
* - probe failure → manual-PAT (connectivity escape hatch).
|
|
335
321
|
*/
|
|
336
322
|
export declare function selectRemoteOnboardingMethod(probe: OidcProbeResult): RemoteOnboardingMethod;
|
|
337
|
-
/**
|
|
338
|
-
* The minimal identity envelope `GET /api/v1/me` returns (task #809). Mirrors
|
|
339
|
-
* the fields {@link writeCredentials} needs so a manually-pasted PAT lands in
|
|
340
|
-
* the SAME credentials file the device flow writes — the bridge then resolves
|
|
341
|
-
* its bearer token from there at runtime (URL-only claude.json entry, #810).
|
|
342
|
-
*/
|
|
343
|
-
export interface ManualPatIdentity {
|
|
344
|
-
id: number;
|
|
345
|
-
displayName: string;
|
|
346
|
-
email: string | null;
|
|
347
|
-
/** Best-effort token rowid; defaults to 1 when the server omits it. */
|
|
348
|
-
tokenId?: number;
|
|
349
|
-
}
|
|
350
|
-
/**
|
|
351
|
-
* Outcome of persisting a manually-supplied PAT (task #809).
|
|
352
|
-
* - `{ ok: true, identity }` — the PAT validated and credentials were written.
|
|
353
|
-
* - `{ ok: false, reason }` — the PAT was rejected / unreachable; `reason`
|
|
354
|
-
* is surfaced to the user and NOTHING is persisted.
|
|
355
|
-
*/
|
|
356
|
-
export type ManualPatPersistResult = {
|
|
357
|
-
ok: true;
|
|
358
|
-
identity: ManualPatIdentity;
|
|
359
|
-
} | {
|
|
360
|
-
ok: false;
|
|
361
|
-
reason: string;
|
|
362
|
-
};
|
|
363
|
-
/** Injectable manual-PAT persistence seam so tests drive it without a server. */
|
|
364
|
-
export type ManualPatPersist = (baseUrl: string, token: string) => Promise<ManualPatPersistResult>;
|
|
365
|
-
/**
|
|
366
|
-
* Default manual-PAT persistence (task #809).
|
|
367
|
-
*
|
|
368
|
-
* Validate the pasted PAT against `GET <baseUrl>/api/v1/me` (the same identity
|
|
369
|
-
* envelope `tasks whoami` reads), then persist it through {@link writeCredentials}
|
|
370
|
-
* — the SAME credentials writer {@link runDeviceLogin} uses. This is the only
|
|
371
|
-
* place the manual PAT lands; the claude.json entry stays URL-only (#810) and
|
|
372
|
-
* the bridge resolves the bearer token from this credentials file at runtime,
|
|
373
|
-
* so the secret is never embedded in claude.json.
|
|
374
|
-
*
|
|
375
|
-
* A non-2xx / network failure returns `{ ok: false, reason }` and writes
|
|
376
|
-
* NOTHING — the caller reports the reason and exits without a half-configured
|
|
377
|
-
* install.
|
|
378
|
-
*/
|
|
379
|
-
export declare function persistManualPat(baseUrl: string, token: string): Promise<ManualPatPersistResult>;
|
|
380
323
|
export interface RunSetupInteractiveOptions extends RunSetupOptions {
|
|
381
324
|
/**
|
|
382
325
|
* Injectable OIDC-state probe for the `--remote` path (#807). Defaults to
|
|
@@ -5,14 +5,18 @@ import fs from 'node:fs';
|
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
6
|
import { mergeClaudeJson } from '../../setup/claude-json.js';
|
|
7
7
|
import { resolveAssetPath } from '../../assets/resolve.js';
|
|
8
|
-
import { configDir as defaultConfigDir } from '../../config/paths.js';
|
|
9
8
|
import { resolvePathHint } from '../util/path-hint.js';
|
|
10
9
|
import { buildNpmInvocation } from '../util/npm-spawn.js';
|
|
11
|
-
import { selectFromMenu,
|
|
10
|
+
import { selectFromMenu, promptLine } from '../util/prompt.js';
|
|
12
11
|
import { shouldPrompt } from '../prompts/interactive.js';
|
|
13
12
|
import { getServiceBackend } from './service.js';
|
|
14
13
|
import { runDeviceLogin } from './login.js';
|
|
15
|
-
import {
|
|
14
|
+
import { canUseBrowserSso, browserSsoGuidance, persistManualPat, resolveManualPatToken, } from '../auth/manual-pat.js';
|
|
15
|
+
// Re-export the shared manual-PAT primitives (moved to ../auth/manual-pat.ts in
|
|
16
|
+
// #857/#858 so `tasks login` can reuse them) from their historical import site
|
|
17
|
+
// so existing callers/tests that import them from `commands/setup.js` keep
|
|
18
|
+
// working.
|
|
19
|
+
export { canUseBrowserSso, browserSsoGuidance, persistManualPat, };
|
|
16
20
|
/**
|
|
17
21
|
* `tasks setup` (task #737).
|
|
18
22
|
*
|
|
@@ -84,43 +88,6 @@ export function buildRemoteMcpEntry(apiUrl) {
|
|
|
84
88
|
export function commandsDestDir(home = os.homedir()) {
|
|
85
89
|
return path.join(home, '.claude', 'commands', 'tasks');
|
|
86
90
|
}
|
|
87
|
-
/**
|
|
88
|
-
* Absolute path of the cached PAT file under the OS CONFIG dir (task #738).
|
|
89
|
-
*
|
|
90
|
-
* The PAT is operator configuration — NOT persistent application state — so it
|
|
91
|
-
* lives under `configDir` (env-paths, OS-correct), NEVER the data dir where the
|
|
92
|
-
* SQLite DB lives. `configDir` is injectable so tests can sandbox it into a
|
|
93
|
-
* temp directory.
|
|
94
|
-
*/
|
|
95
|
-
export function patCachePath(configDir = defaultConfigDir) {
|
|
96
|
-
return path.join(configDir, 'remote-token');
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* Cache the remote PAT to a file under the CONFIG dir, idempotently. On POSIX
|
|
100
|
-
* the file is created/tightened to 0600 (owner read/write only) so the secret
|
|
101
|
-
* is not world-readable; on Windows the mode arg is a best-effort no-op (NTFS
|
|
102
|
-
* ACLs already restrict the per-user config dir).
|
|
103
|
-
*/
|
|
104
|
-
export function cachePat(token, configDir = defaultConfigDir) {
|
|
105
|
-
const filePath = patCachePath(configDir);
|
|
106
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
107
|
-
const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : null;
|
|
108
|
-
const changed = existing !== token;
|
|
109
|
-
if (changed) {
|
|
110
|
-
fs.writeFileSync(filePath, token, { encoding: 'utf8', mode: 0o600 });
|
|
111
|
-
}
|
|
112
|
-
// Tighten perms on POSIX even when bytes were unchanged (defensive). chmod is
|
|
113
|
-
// a best-effort no-op semantically on Windows; guard so it never throws.
|
|
114
|
-
if (process.platform !== 'win32') {
|
|
115
|
-
try {
|
|
116
|
-
fs.chmodSync(filePath, 0o600);
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
/* best-effort: never block setup on a chmod failure */
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
return { path: filePath, changed };
|
|
123
|
-
}
|
|
124
91
|
/**
|
|
125
92
|
* Copy every `*.md` skill from the asset-resolver source into destDir.
|
|
126
93
|
* Idempotent: a file is only (re)written when its bytes differ.
|
|
@@ -384,55 +351,27 @@ export function fixNpmPrefix(options = {}) {
|
|
|
384
351
|
return { prefix, binDir, guidance };
|
|
385
352
|
}
|
|
386
353
|
/**
|
|
387
|
-
* Pure-ish setup action. Resolves all paths from `home` so tests can
|
|
388
|
-
* with a temp HOME and never touch the real ~/.claude.json or ~/.claude/.
|
|
354
|
+
* Pure-ish LOCAL setup action. Resolves all paths from `home` so tests can
|
|
355
|
+
* sandbox with a temp HOME and never touch the real ~/.claude.json or ~/.claude/.
|
|
356
|
+
*
|
|
357
|
+
* Remote onboarding is NOT handled here — it requires an async server round-trip
|
|
358
|
+
* to validate + persist a credential (#858). {@link runSetupInteractive} routes
|
|
359
|
+
* `--remote` to {@link runRemoteOnboarding} / the manual-PAT path, which write
|
|
360
|
+
* the URL-only bridge entry via {@link writeRemoteMcpEntryOnly}.
|
|
389
361
|
*/
|
|
390
362
|
export function runSetup(options = {}) {
|
|
391
363
|
const home = options.home ?? os.homedir();
|
|
392
364
|
const log = options.log ?? ((line) => console.log(line));
|
|
393
365
|
const claudeJsonPath = path.join(home, '.claude.json');
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if (isRemote) {
|
|
404
|
-
const apiUrl = options.remote;
|
|
405
|
-
const token = options.token;
|
|
406
|
-
if (typeof token !== 'string' || token.length === 0) {
|
|
407
|
-
throw new Error('--remote requires --token <pat> so the remote PAT can be cached for ' +
|
|
408
|
-
'the bridge to use at runtime.');
|
|
409
|
-
}
|
|
410
|
-
serverName = REMOTE_SERVER_NAME;
|
|
411
|
-
merge = mergeClaudeJson({
|
|
412
|
-
filePath: claudeJsonPath,
|
|
413
|
-
serverName: REMOTE_SERVER_NAME,
|
|
414
|
-
entry: buildRemoteMcpEntry(apiUrl),
|
|
415
|
-
});
|
|
416
|
-
log(!merge.unchanged
|
|
417
|
-
? `Installed remote MCP server '${REMOTE_SERVER_NAME}' into ${claudeJsonPath}`
|
|
418
|
-
: `Remote MCP server '${REMOTE_SERVER_NAME}' already present in ${claudeJsonPath}`);
|
|
419
|
-
// Cache the PAT under the CONFIG dir (NOT the data dir).
|
|
420
|
-
patCache = cachePat(token, options.configDir);
|
|
421
|
-
log(patCache.changed
|
|
422
|
-
? `Cached remote PAT at ${patCache.path}`
|
|
423
|
-
: `Remote PAT already cached at ${patCache.path}`);
|
|
424
|
-
}
|
|
425
|
-
else {
|
|
426
|
-
serverName = SERVER_NAME;
|
|
427
|
-
merge = mergeClaudeJson({
|
|
428
|
-
filePath: claudeJsonPath,
|
|
429
|
-
serverName: SERVER_NAME,
|
|
430
|
-
entry: buildLocalMcpEntry(),
|
|
431
|
-
});
|
|
432
|
-
log(!merge.unchanged
|
|
433
|
-
? `Installed local MCP server '${SERVER_NAME}' into ${claudeJsonPath}`
|
|
434
|
-
: `Local MCP server '${SERVER_NAME}' already present in ${claudeJsonPath}`);
|
|
435
|
-
}
|
|
366
|
+
const serverName = SERVER_NAME;
|
|
367
|
+
const merge = mergeClaudeJson({
|
|
368
|
+
filePath: claudeJsonPath,
|
|
369
|
+
serverName: SERVER_NAME,
|
|
370
|
+
entry: buildLocalMcpEntry(),
|
|
371
|
+
});
|
|
372
|
+
log(!merge.unchanged
|
|
373
|
+
? `Installed local MCP server '${SERVER_NAME}' into ${claudeJsonPath}`
|
|
374
|
+
: `Local MCP server '${SERVER_NAME}' already present in ${claudeJsonPath}`);
|
|
436
375
|
// Task #752: ~/.claude.json can carry the local-credentials PAT and the
|
|
437
376
|
// remote WFT_API_KEY env, so tighten it to owner-only (0600) on POSIX after
|
|
438
377
|
// the merge writes it. Best-effort + guarded so a chmod failure (e.g. an
|
|
@@ -479,23 +418,22 @@ export function runSetup(options = {}) {
|
|
|
479
418
|
claudeJsonPath,
|
|
480
419
|
claudeJsonChanged: !merge.unchanged,
|
|
481
420
|
serverName,
|
|
482
|
-
remote:
|
|
421
|
+
remote: false,
|
|
483
422
|
skills,
|
|
484
423
|
agents,
|
|
485
424
|
...(npmPrefix !== undefined && { npmPrefix }),
|
|
486
|
-
...(patCache !== undefined && { patCache }),
|
|
487
425
|
};
|
|
488
426
|
}
|
|
489
427
|
/**
|
|
490
428
|
* Write ONLY the URL-only remote MCP bridge entry into ~/.claude.json (and copy
|
|
491
429
|
* skills/agents), WITHOUT requiring or caching a PAT (task #808).
|
|
492
430
|
*
|
|
493
|
-
* This is the
|
|
494
|
-
*
|
|
495
|
-
* it via the credentials writer, there is no token to embed
|
|
431
|
+
* This is the shared remote-entry writer for BOTH onboarding paths: after
|
|
432
|
+
* {@link runDeviceLogin} (#806) or the manual-PAT path has provisioned the PAT
|
|
433
|
+
* and persisted it via the credentials writer, there is no token to embed —
|
|
496
434
|
* the claude.json entry must be URL-only (#810). So this helper mirrors
|
|
497
|
-
* runSetup's claude.json/skills/agents work but
|
|
498
|
-
*
|
|
435
|
+
* runSetup's claude.json/skills/agents work but writes the REMOTE bridge entry
|
|
436
|
+
* and persists NO token of its own (the credentials file is the sole owner).
|
|
499
437
|
*
|
|
500
438
|
* The written entry is exactly `buildRemoteMcpEntry(apiUrl)`: a stdio bridge
|
|
501
439
|
* carrying only `WFT_API_URL`. The bridge resolves its bearer token at runtime
|
|
@@ -604,71 +542,6 @@ export function selectRemoteOnboardingMethod(probe) {
|
|
|
604
542
|
return 'manual-pat';
|
|
605
543
|
return probe.oidc === 'ready' ? 'device-flow' : 'manual-pat';
|
|
606
544
|
}
|
|
607
|
-
/**
|
|
608
|
-
* Default manual-PAT persistence (task #809).
|
|
609
|
-
*
|
|
610
|
-
* Validate the pasted PAT against `GET <baseUrl>/api/v1/me` (the same identity
|
|
611
|
-
* envelope `tasks whoami` reads), then persist it through {@link writeCredentials}
|
|
612
|
-
* — the SAME credentials writer {@link runDeviceLogin} uses. This is the only
|
|
613
|
-
* place the manual PAT lands; the claude.json entry stays URL-only (#810) and
|
|
614
|
-
* the bridge resolves the bearer token from this credentials file at runtime,
|
|
615
|
-
* so the secret is never embedded in claude.json.
|
|
616
|
-
*
|
|
617
|
-
* A non-2xx / network failure returns `{ ok: false, reason }` and writes
|
|
618
|
-
* NOTHING — the caller reports the reason and exits without a half-configured
|
|
619
|
-
* install.
|
|
620
|
-
*/
|
|
621
|
-
export async function persistManualPat(baseUrl, token) {
|
|
622
|
-
const meUrl = new URL('/api/v1/me', baseUrl).toString();
|
|
623
|
-
let response;
|
|
624
|
-
try {
|
|
625
|
-
response = await fetch(meUrl, { headers: { Authorization: `Bearer ${token}` } });
|
|
626
|
-
}
|
|
627
|
-
catch (err) {
|
|
628
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
629
|
-
return { ok: false, reason: `could not reach ${meUrl}: ${message}` };
|
|
630
|
-
}
|
|
631
|
-
if (response.status === 401) {
|
|
632
|
-
return { ok: false, reason: 'the personal access token was rejected (HTTP 401)' };
|
|
633
|
-
}
|
|
634
|
-
if (!response.ok) {
|
|
635
|
-
return { ok: false, reason: `${meUrl} returned HTTP ${response.status}` };
|
|
636
|
-
}
|
|
637
|
-
let body;
|
|
638
|
-
try {
|
|
639
|
-
body = (await response.json());
|
|
640
|
-
}
|
|
641
|
-
catch {
|
|
642
|
-
return { ok: false, reason: `${meUrl} returned a non-JSON body` };
|
|
643
|
-
}
|
|
644
|
-
if (body === null || typeof body.id !== 'number' || typeof body.displayName !== 'string') {
|
|
645
|
-
return { ok: false, reason: `${meUrl} did not return a usable identity` };
|
|
646
|
-
}
|
|
647
|
-
const email = typeof body.email === 'string' ? body.email : null;
|
|
648
|
-
const identity = { id: body.id, displayName: body.displayName, email };
|
|
649
|
-
// Persist through the SAME credentials writer the device flow uses. The
|
|
650
|
-
// server's /me envelope does not carry the token rowid, so default token_id
|
|
651
|
-
// to 1 (a positive int, satisfying the credentials schema); `whoami`'s
|
|
652
|
-
// best-effort token enrichment degrades gracefully when it can't match it.
|
|
653
|
-
try {
|
|
654
|
-
writeCredentials({
|
|
655
|
-
active: {
|
|
656
|
-
token,
|
|
657
|
-
token_id: 1,
|
|
658
|
-
server: baseUrl,
|
|
659
|
-
user_id: identity.id,
|
|
660
|
-
display_name: identity.displayName,
|
|
661
|
-
email: identity.email,
|
|
662
|
-
logged_in_at: new Date().toISOString(),
|
|
663
|
-
},
|
|
664
|
-
});
|
|
665
|
-
}
|
|
666
|
-
catch (err) {
|
|
667
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
668
|
-
return { ok: false, reason: `failed to write credentials file: ${message}` };
|
|
669
|
-
}
|
|
670
|
-
return { ok: true, identity };
|
|
671
|
-
}
|
|
672
545
|
/** Present the Local / Service / Remote menu and resolve the chosen mode. */
|
|
673
546
|
export function selectSetupMode(io) {
|
|
674
547
|
return selectFromMenu({
|
|
@@ -718,15 +591,15 @@ export async function runSetupInteractive(options = {}) {
|
|
|
718
591
|
serviceInstall();
|
|
719
592
|
return { mode: 'service' };
|
|
720
593
|
}
|
|
721
|
-
// Remote: an explicit `--token` is the NON-INTERACTIVE direct path
|
|
722
|
-
// (automation / CI)
|
|
723
|
-
//
|
|
724
|
-
//
|
|
725
|
-
//
|
|
726
|
-
// runs the probe-driven device-flow / manual-PAT onboarding
|
|
727
|
-
// (runRemoteOnboarding),
|
|
728
|
-
//
|
|
729
|
-
//
|
|
594
|
+
// Remote: an explicit `--token` is the NON-INTERACTIVE direct manual-PAT path
|
|
595
|
+
// (automation / CI). It validates the PAT against /api/v1/me, persists it to
|
|
596
|
+
// the credentials file (the single source of truth the CLI + bridge read), and
|
|
597
|
+
// writes the URL-only remote bridge entry (WFT_API_URL only — the PAT is NEVER
|
|
598
|
+
// persisted into claude.json, #810), skipping only the OIDC probe. A TOKENLESS
|
|
599
|
+
// `--remote` runs the probe-driven device-flow / manual-PAT onboarding
|
|
600
|
+
// (runRemoteOnboarding), for the interactive operator who has no PAT yet.
|
|
601
|
+
// Require `remote` too so a programmatic caller passing {mode:'remote', token}
|
|
602
|
+
// without a URL can't get a LOCAL install mislabeled as remote.
|
|
730
603
|
if (mode === 'remote') {
|
|
731
604
|
const hasToken = typeof options.token === 'string' && options.token.length > 0;
|
|
732
605
|
let hasRemote = typeof options.remote === 'string' && options.remote.length > 0;
|
|
@@ -743,9 +616,17 @@ export async function runSetupInteractive(options = {}) {
|
|
|
743
616
|
hasRemote = true;
|
|
744
617
|
}
|
|
745
618
|
}
|
|
619
|
+
// #858: an explicit `--token` is the NON-INTERACTIVE direct manual-PAT path
|
|
620
|
+
// (automation / CI). It MUST persist a usable credential — validate the PAT
|
|
621
|
+
// against /api/v1/me and writeCredentials via completeManualPatOnboarding,
|
|
622
|
+
// then write the URL-only bridge entry — exactly like the interactive
|
|
623
|
+
// manual-PAT path. (The old code routed here to runSetup, which only cached
|
|
624
|
+
// the PAT to an ORPHANED file that nothing reads, leaving both the CLI and
|
|
625
|
+
// the MCP bridge unauthenticated despite reporting success.) Skips the OIDC
|
|
626
|
+
// probe — we already have a PAT — but the /api/v1/me validation round-trip is
|
|
627
|
+
// required to learn the identity the credentials file needs.
|
|
746
628
|
if (hasToken && hasRemote) {
|
|
747
|
-
|
|
748
|
-
return { mode: 'remote', oidc: null, method: 'manual-pat', ok: true, setup };
|
|
629
|
+
return completeManualPatOnboarding(options, options.remote, null);
|
|
749
630
|
}
|
|
750
631
|
return runRemoteOnboarding(options);
|
|
751
632
|
}
|
|
@@ -811,6 +692,17 @@ export async function runRemoteOnboarding(options = {}) {
|
|
|
811
692
|
// 3. Branch on the selected method.
|
|
812
693
|
let method = selectRemoteOnboardingMethod(probeResult);
|
|
813
694
|
const oidc = probeResult.ok ? probeResult.oidc : null;
|
|
695
|
+
// #835: even when the server reports OIDC ready, browser login can only
|
|
696
|
+
// COMPLETE over https (or localhost) — Google rejects non-https OAuth
|
|
697
|
+
// redirect URIs everywhere else. Catch a plain-http non-localhost URL here
|
|
698
|
+
// and tell the user plainly, then route to manual-PAT entry (with on-host
|
|
699
|
+
// mint instructions) rather than opening a verification URL that dead-ends at
|
|
700
|
+
// the IdP callback.
|
|
701
|
+
if (method === 'device-flow' && !canUseBrowserSso(baseUrl)) {
|
|
702
|
+
for (const line of browserSsoGuidance(baseUrl))
|
|
703
|
+
log(line);
|
|
704
|
+
method = 'manual-pat';
|
|
705
|
+
}
|
|
814
706
|
if (method === 'device-flow') {
|
|
815
707
|
// Self-provision a PAT via the OIDC device flow (#806). runDeviceLogin owns
|
|
816
708
|
// the entire RFC 8628 exchange AND persists the minted PAT via the
|
|
@@ -854,39 +746,46 @@ export async function runRemoteOnboarding(options = {}) {
|
|
|
854
746
|
return { mode: 'remote', oidc, method, ok: true, setup };
|
|
855
747
|
}
|
|
856
748
|
// Device flow unavailable/aborted → fall through to the manual-PAT path
|
|
857
|
-
//
|
|
749
|
+
// (records the method actually used so the result reflects reality).
|
|
858
750
|
method = 'manual-pat';
|
|
859
751
|
}
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
752
|
+
return completeManualPatOnboarding(options, baseUrl, oidc);
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Shared manual-PAT onboarding tail (#858/#857): obtain a PAT, validate it
|
|
756
|
+
* against `GET /api/v1/me`, persist it through {@link persistManualPat} (the
|
|
757
|
+
* SAME credentials writer the device flow uses), then write the URL-only
|
|
758
|
+
* claude.json bridge entry via {@link writeRemoteMcpEntryOnly} (#808/#810). The
|
|
759
|
+
* claude.json entry is identical regardless of how the PAT was obtained — only
|
|
760
|
+
* the PAT source differs.
|
|
761
|
+
*
|
|
762
|
+
* This is invoked from TWO places, so the logic can't drift (the root cause of
|
|
763
|
+
* #858, where the non-interactive `--remote --token` path took a DIFFERENT,
|
|
764
|
+
* credential-less route):
|
|
765
|
+
* 1. {@link runRemoteOnboarding}'s manual-PAT branch (probe said no browser
|
|
766
|
+
* login, or device flow failed/aborted).
|
|
767
|
+
* 2. {@link runSetupInteractive}'s non-interactive `--remote --token` path.
|
|
768
|
+
*
|
|
769
|
+
* PAT precedence (via {@link resolveManualPatToken}):
|
|
770
|
+
* 1. `--token <pat>` (explicit flag; wins).
|
|
771
|
+
* 2. interactive promptSecret (a TTY pastes the PAT, never echoed).
|
|
772
|
+
* 3. env `WFT_API_KEY` (DOCUMENTED non-TTY fallback — the same env the
|
|
773
|
+
* remote bridge reads at runtime; lets CI / non-TTY callers supply the PAT
|
|
774
|
+
* without a prompt instead of hanging).
|
|
775
|
+
* On a non-TTY with none of the above, fail clearly (no hang) and write NOTHING.
|
|
776
|
+
*/
|
|
777
|
+
async function completeManualPatOnboarding(options, baseUrl, oidc) {
|
|
778
|
+
const log = options.log ?? ((line) => console.log(line));
|
|
779
|
+
const token = await resolveManualPatToken({
|
|
780
|
+
...(options.token !== undefined && { token: options.token }),
|
|
781
|
+
...(options.promptIO !== undefined && { promptIO: options.promptIO }),
|
|
782
|
+
...(options.isInteractive !== undefined && { isInteractive: options.isInteractive }),
|
|
783
|
+
envVar: 'WFT_API_KEY',
|
|
784
|
+
});
|
|
785
|
+
if (token === undefined) {
|
|
887
786
|
log('No personal access token supplied. Re-run with --token <pat> ' +
|
|
888
787
|
'or set the WFT_API_KEY environment variable to finish remote setup.');
|
|
889
|
-
return { mode: 'remote', oidc, method, ok: false };
|
|
788
|
+
return { mode: 'remote', oidc, method: 'manual-pat', ok: false };
|
|
890
789
|
}
|
|
891
790
|
// Validate the PAT and persist it via writeCredentials (the device-flow
|
|
892
791
|
// writer). Nothing is written to claude.json until the PAT proves valid, so a
|
|
@@ -895,7 +794,7 @@ export async function runRemoteOnboarding(options = {}) {
|
|
|
895
794
|
const persisted = await persist(baseUrl, token);
|
|
896
795
|
if (!persisted.ok) {
|
|
897
796
|
log(`Could not store the personal access token: ${persisted.reason}.`);
|
|
898
|
-
return { mode: 'remote', oidc, method, ok: false };
|
|
797
|
+
return { mode: 'remote', oidc, method: 'manual-pat', ok: false };
|
|
899
798
|
}
|
|
900
799
|
log(`Stored credentials for ${persisted.identity.displayName}.`);
|
|
901
800
|
// URL-only claude.json entry (#810) via the SAME helper the device-flow path
|
|
@@ -905,7 +804,7 @@ export async function runRemoteOnboarding(options = {}) {
|
|
|
905
804
|
return {
|
|
906
805
|
mode: 'remote',
|
|
907
806
|
oidc,
|
|
908
|
-
method,
|
|
807
|
+
method: 'manual-pat',
|
|
909
808
|
ok: true,
|
|
910
809
|
setup,
|
|
911
810
|
manualPatIdentity: persisted.identity,
|
|
@@ -917,7 +816,7 @@ export const setupCommand = new Command('setup')
|
|
|
917
816
|
.option('--local', 'Run the Local setup path non-interactively (skip the menu)')
|
|
918
817
|
.option('--service', 'Run the Service setup path non-interactively (user-scoped service install)')
|
|
919
818
|
.option('--remote <url>', 'Install the remote MCP bridge (wood-fired-tasks-remote) pointed at the given REST API base URL; requires --token')
|
|
920
|
-
.option('--token <pat>', 'Personal access token for `--remote`. When supplied, setup
|
|
819
|
+
.option('--token <pat>', 'Personal access token for `--remote`. When supplied, setup validates it against the server, persists it to the credentials file (the PAT is never stored in claude.json), and writes the URL-only remote MCP entry (WFT_API_URL) — skipping the OIDC probe. Omit --token to run the interactive device-flow / manual-PAT onboarding instead.')
|
|
921
820
|
.action((opts) => {
|
|
922
821
|
// `--token` is ALSO a global option on the root program (src/cli/bin/tasks.ts),
|
|
923
822
|
// registered for Bearer-auth override. When a user runs
|
|
@@ -35,8 +35,8 @@ export interface PromptIO {
|
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
37
|
* Read a single line of input from the given stream, resolving when the first
|
|
38
|
-
*
|
|
39
|
-
*
|
|
38
|
+
* line terminator (`\n`, `\r`, or `\r\n`) is seen or the stream ends. The
|
|
39
|
+
* terminator is stripped from the returned value.
|
|
40
40
|
*
|
|
41
41
|
* @param prompt - Message written to the output stream before reading.
|
|
42
42
|
* @param io - Injectable input/output streams (defaults to process streams).
|
package/dist/cli/util/prompt.js
CHANGED
|
@@ -7,8 +7,8 @@ function resolveOutput(io) {
|
|
|
7
7
|
}
|
|
8
8
|
/**
|
|
9
9
|
* Read a single line of input from the given stream, resolving when the first
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* line terminator (`\n`, `\r`, or `\r\n`) is seen or the stream ends. The
|
|
11
|
+
* terminator is stripped from the returned value.
|
|
12
12
|
*
|
|
13
13
|
* @param prompt - Message written to the output stream before reading.
|
|
14
14
|
* @param io - Injectable input/output streams (defaults to process streams).
|
|
@@ -114,8 +114,15 @@ export async function selectFromMenu(config, io) {
|
|
|
114
114
|
}
|
|
115
115
|
/**
|
|
116
116
|
* Resolve with the first line emitted by `input`. Accumulates chunks until a
|
|
117
|
-
*
|
|
118
|
-
* first, resolves with whatever was buffered.
|
|
117
|
+
* line terminator is seen — `\n`, `\r`, or `\r\n`, all stripped — then resolves;
|
|
118
|
+
* if the stream ends first, resolves with whatever was buffered.
|
|
119
|
+
*
|
|
120
|
+
* Accepting a BARE `\r` is load-bearing for {@link promptSecret} on a real TTY
|
|
121
|
+
* (task #856): raw mode disables the terminal's CR→LF translation, so the Enter
|
|
122
|
+
* key delivers `\r` (0x0D) with NO following `\n`. Terminating only on `\n` left
|
|
123
|
+
* the read hanging forever (the pasted secret buffered, Enter never recognized).
|
|
124
|
+
* The unit tests never caught it because injected fake streams feed `\n`, which
|
|
125
|
+
* never exercises raw mode.
|
|
119
126
|
*/
|
|
120
127
|
function readLineFrom(input) {
|
|
121
128
|
return new Promise((resolve, reject) => {
|
|
@@ -140,13 +147,25 @@ function readLineFrom(input) {
|
|
|
140
147
|
};
|
|
141
148
|
const onData = (chunk) => {
|
|
142
149
|
buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
+
// Terminate on the FIRST `\r` or `\n` (#856). A bare `\r` is what raw-mode
|
|
151
|
+
// Enter sends (no CR→LF translation), so we must not wait for a `\n` that
|
|
152
|
+
// will never arrive. `slice(0, termIdx)` drops the terminator and — for
|
|
153
|
+
// `\r\n` — leaves the trailing `\n` in `buffer`, which is discarded since
|
|
154
|
+
// the line is already settled.
|
|
155
|
+
const crIdx = buffer.indexOf('\r');
|
|
156
|
+
const lfIdx = buffer.indexOf('\n');
|
|
157
|
+
let termIdx = -1;
|
|
158
|
+
if (crIdx !== -1 && lfIdx !== -1) {
|
|
159
|
+
termIdx = Math.min(crIdx, lfIdx);
|
|
160
|
+
}
|
|
161
|
+
else if (crIdx !== -1) {
|
|
162
|
+
termIdx = crIdx;
|
|
163
|
+
}
|
|
164
|
+
else if (lfIdx !== -1) {
|
|
165
|
+
termIdx = lfIdx;
|
|
166
|
+
}
|
|
167
|
+
if (termIdx !== -1) {
|
|
168
|
+
finish(buffer.slice(0, termIdx));
|
|
150
169
|
}
|
|
151
170
|
};
|
|
152
171
|
const onEnd = () => {
|
package/docs/CLI.md
CHANGED
|
@@ -48,8 +48,9 @@ The CLI requires these environment variables to connect to the API server:
|
|
|
48
48
|
|
|
49
49
|
[IMPORTANT] Authentication uses a Personal Access Token (PAT) sent as
|
|
50
50
|
`Authorization: Bearer <pat>`. For interactive use prefer
|
|
51
|
-
[`tasks login`](#tasks-login) (OIDC device flow
|
|
52
|
-
|
|
51
|
+
[`tasks login`](#tasks-login) (OIDC device flow, or `--token <pat>` for a manual
|
|
52
|
+
PAT on non-`https` servers; writes a PAT to the credentials file, which takes
|
|
53
|
+
precedence over `API_KEY`). For scripting/CI, set `API_KEY` to
|
|
53
54
|
a `wft_pat_…` value or pass `--token wft_pat_…`. Mint a PAT via the web UI (`/me`),
|
|
54
55
|
`tasks login`, or `tasks db mint-token` (headless bootstrap).
|
|
55
56
|
|
|
@@ -848,12 +849,19 @@ These commands manage the local credentials file used for Bearer (PAT) authentic
|
|
|
848
849
|
|
|
849
850
|
### tasks login
|
|
850
851
|
|
|
851
|
-
Authenticate with the WFT server
|
|
852
|
+
Authenticate with the WFT server and write a Personal Access Token to the credentials file. The PAT value itself is never printed (stdout or stderr). Two paths:
|
|
853
|
+
|
|
854
|
+
- **Device flow (default).** Requests a device code, surfaces a verification URL and user code, best-effort opens a browser, then polls until you approve. Used when browser login can complete against the server — that is, an `https` URL or `http://localhost` / `127.0.0.1`.
|
|
855
|
+
- **Manual PAT.** Triggered when you pass `--token <pat>`, *or* automatically when browser login can't complete against the target server (a plain-`http` non-localhost URL — identity providers like Google reject non-`https` OAuth redirect URIs, so the device flow would dead-end). The PAT is validated against `GET /api/v1/me` and persisted to the credentials file. When no `--token` is given on such a server, `login` prints the same `https`-required / how-to-mint-a-PAT guidance `tasks setup` shows, then (on a TTY) prompts you to paste one.
|
|
856
|
+
|
|
857
|
+
This makes `tasks login` reach parity with `tasks setup --remote`: a remote non-`https` server is no longer a dead end. Both commands share the manual-PAT logic (`canUseBrowserSso` gate + `persistManualPat`), so they can't drift.
|
|
858
|
+
|
|
859
|
+
> Note: the login-command `--token <pat>` flag (which **persists** a credential) is distinct from the global `--token` flag (which sets a per-invocation Bearer header for outbound API calls and does **not** persist anything). `tasks login --token …` and `tasks --token … login` both reach the manual-PAT persistence path.
|
|
852
860
|
|
|
853
861
|
**Examples:**
|
|
854
862
|
|
|
855
863
|
```bash
|
|
856
|
-
# Standard interactive login
|
|
864
|
+
# Standard interactive device-flow login (https / localhost server)
|
|
857
865
|
tasks login
|
|
858
866
|
|
|
859
867
|
# Don't auto-open a browser (print the URL only)
|
|
@@ -861,23 +869,28 @@ tasks login --no-browser
|
|
|
861
869
|
|
|
862
870
|
# Override the server for this login (stored in the credentials file)
|
|
863
871
|
tasks login --server https://tasks.example.com
|
|
872
|
+
|
|
873
|
+
# Manual PAT — required for a remote plain-http / LAN-IP server where
|
|
874
|
+
# Google SSO can't complete. Validated against /api/v1/me, then persisted.
|
|
875
|
+
tasks login --server http://tasks.example.local:3000 --token wft_pat_…
|
|
864
876
|
```
|
|
865
877
|
|
|
866
878
|
**Options:**
|
|
867
879
|
|
|
868
880
|
| Option | Type | Description |
|
|
869
881
|
|--------|------|-------------|
|
|
882
|
+
| --token | string | Authenticate with a Personal Access Token instead of the device flow (required for remote non-`https` servers). Validated against `GET /api/v1/me`, then stored in the credentials file. |
|
|
870
883
|
| --token-name | string | Name for the minted PAT (advisory in v1.6; reserved for v1.7 explicit naming) |
|
|
871
884
|
| --no-browser | flag | Skip auto-opening the verification URL in a browser |
|
|
872
885
|
| --server | string | Override `API_BASE_URL` for this invocation (persisted to the credentials file) |
|
|
873
886
|
|
|
874
887
|
**Output:**
|
|
875
888
|
|
|
876
|
-
In text mode, login chrome (verification URL, user code, progress) is written to **stderr**, so `tasks login && tasks list` keeps stdout clean. On success it prints `Logged in as <displayName>`. With `--json`, a sequence of newline-separated JSON event envelopes is written to stdout
|
|
889
|
+
In text mode, login chrome (verification URL, user code, progress, or the manual-PAT guidance) is written to **stderr**, so `tasks login && tasks list` keeps stdout clean. On success it prints `Logged in as <displayName>`. With `--json`, a sequence of newline-separated JSON event envelopes is written to stdout — device flow: `{event:"pending"}`, optional `{event:"slow_down"}`, then `{event:"logged_in"}` or `{event:"failed"}`; manual PAT: a single `{event:"logged_in"}` or `{event:"failed"}`.
|
|
877
890
|
|
|
878
891
|
**Exit codes:**
|
|
879
892
|
|
|
880
|
-
Returns `0` on a successful login. Returns `1` on an invalid server URL, a failed device-code request, a terminal polling error, or a credentials-write failure.
|
|
893
|
+
Returns `0` on a successful login. Returns `1` on an invalid server URL, a failed device-code request, a terminal polling error, a rejected/unreachable PAT, no PAT supplied on a non-`https` server, or a credentials-write failure.
|
|
881
894
|
|
|
882
895
|
### tasks logout
|
|
883
896
|
|
package/docs/SETUP.md
CHANGED
|
@@ -106,17 +106,28 @@ wood-fired-tasks setup \
|
|
|
106
106
|
--token wft_pat_…this-machine…
|
|
107
107
|
```
|
|
108
108
|
|
|
109
|
-
This writes a
|
|
110
|
-
from the local `wood-fired-tasks` entry — the two coexist) whose `env`
|
|
111
|
-
`WFT_API_URL` (the base URL)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
`
|
|
119
|
-
|
|
109
|
+
This writes a **URL-only `wood-fired-tasks-remote`** entry to `~/.claude.json`
|
|
110
|
+
(distinct from the local `wood-fired-tasks` entry — the two coexist) whose `env`
|
|
111
|
+
carries **only** `WFT_API_URL` (the base URL). The PAT is **never** embedded in
|
|
112
|
+
`~/.claude.json` (#810). That entry spawns the remote stdio bridge
|
|
113
|
+
(`dist/mcp/remote/index.js`), which proxies every MCP tool call to the REST API
|
|
114
|
+
over HTTP, so every machine sees one backlog.
|
|
115
|
+
|
|
116
|
+
When you pass `--token <pat>`, setup **validates it against `GET /api/v1/me`** and
|
|
117
|
+
**persists it to the CLI credentials file** (`~/.config/wood-fired-tasks/credentials`,
|
|
118
|
+
mode `0600` on POSIX) — the *same* file `tasks login` writes. The remote bridge
|
|
119
|
+
then resolves its bearer token from that credentials file at runtime. There is no
|
|
120
|
+
separate PAT "cache" file (the old `remote-token` cache was removed in #858 —
|
|
121
|
+
nothing read it, so `setup --remote --token` reported success while leaving both
|
|
122
|
+
the CLI and the bridge unauthenticated).
|
|
123
|
+
|
|
124
|
+
`--token` is **optional**: omit it and `setup --remote <url>` runs the interactive
|
|
125
|
+
onboarding — the OIDC **device flow** when the server supports browser login
|
|
126
|
+
(https / localhost), otherwise a **manual-PAT** prompt. Supply `--token` (or set
|
|
127
|
+
`WFT_API_KEY` for non-TTY/CI callers) for the non-interactive direct path, which
|
|
128
|
+
skips the OIDC probe but still performs the `/api/v1/me` validation round-trip.
|
|
129
|
+
Mint a per-machine PAT with `tasks db mint-token` (see
|
|
130
|
+
[Bootstrap a PAT without a browser](#6-bootstrap-a-pat-without-a-browser-servers-ci-headless-agents))
|
|
120
131
|
and revoke it independently to cut off a single client. For the full
|
|
121
132
|
Windows/Linux/macOS fleet recipe, see
|
|
122
133
|
[Multi-OS client fleet](#multi-os-client-fleet-one-shared-on-prem-server).
|