wood-fired-tasks 2.0.5 → 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 +39 -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 -98
- package/dist/cli/commands/setup.js +89 -243
- 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,45 @@ 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
|
+
|
|
16
55
|
## [v2.0.5] - 2026-06-08
|
|
17
56
|
|
|
18
57
|
### Changed
|
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,66 +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
|
-
* Whether the browser/device login (Google SSO) can actually COMPLETE against
|
|
339
|
-
* `baseUrl` (#835).
|
|
340
|
-
*
|
|
341
|
-
* The whole OIDC dance — the verification page AND the IdP's OAuth callback —
|
|
342
|
-
* happens at the server's origin, and identity providers (Google especially)
|
|
343
|
-
* reject non-`https` OAuth redirect URIs *except* for `localhost`/`127.0.0.1`.
|
|
344
|
-
* So a server reached over plain `http` at a non-localhost address can report
|
|
345
|
-
* `oidc: 'ready'` yet still be unable to finish browser login: the user's
|
|
346
|
-
* browser gets bounced to an `http://…/auth/callback` the IdP won't honor. We
|
|
347
|
-
* detect that up front so the interview can tell the user the truth (need https,
|
|
348
|
-
* or use a PAT) instead of opening a URL that dead-ends.
|
|
349
|
-
*
|
|
350
|
-
* Returns true for any `https` URL and for `http://localhost` / `127.0.0.1` /
|
|
351
|
-
* `[::1]`; false for plain-http non-loopback hosts and unparseable input.
|
|
352
|
-
*/
|
|
353
|
-
export declare function canUseBrowserSso(baseUrl: string): boolean;
|
|
354
|
-
/**
|
|
355
|
-
* The minimal identity envelope `GET /api/v1/me` returns (task #809). Mirrors
|
|
356
|
-
* the fields {@link writeCredentials} needs so a manually-pasted PAT lands in
|
|
357
|
-
* the SAME credentials file the device flow writes — the bridge then resolves
|
|
358
|
-
* its bearer token from there at runtime (URL-only claude.json entry, #810).
|
|
359
|
-
*/
|
|
360
|
-
export interface ManualPatIdentity {
|
|
361
|
-
id: number;
|
|
362
|
-
displayName: string;
|
|
363
|
-
email: string | null;
|
|
364
|
-
/** Best-effort token rowid; defaults to 1 when the server omits it. */
|
|
365
|
-
tokenId?: number;
|
|
366
|
-
}
|
|
367
|
-
/**
|
|
368
|
-
* Outcome of persisting a manually-supplied PAT (task #809).
|
|
369
|
-
* - `{ ok: true, identity }` — the PAT validated and credentials were written.
|
|
370
|
-
* - `{ ok: false, reason }` — the PAT was rejected / unreachable; `reason`
|
|
371
|
-
* is surfaced to the user and NOTHING is persisted.
|
|
372
|
-
*/
|
|
373
|
-
export type ManualPatPersistResult = {
|
|
374
|
-
ok: true;
|
|
375
|
-
identity: ManualPatIdentity;
|
|
376
|
-
} | {
|
|
377
|
-
ok: false;
|
|
378
|
-
reason: string;
|
|
379
|
-
};
|
|
380
|
-
/** Injectable manual-PAT persistence seam so tests drive it without a server. */
|
|
381
|
-
export type ManualPatPersist = (baseUrl: string, token: string) => Promise<ManualPatPersistResult>;
|
|
382
|
-
/**
|
|
383
|
-
* Default manual-PAT persistence (task #809).
|
|
384
|
-
*
|
|
385
|
-
* Validate the pasted PAT against `GET <baseUrl>/api/v1/me` (the same identity
|
|
386
|
-
* envelope `tasks whoami` reads), then persist it through {@link writeCredentials}
|
|
387
|
-
* — the SAME credentials writer {@link runDeviceLogin} uses. This is the only
|
|
388
|
-
* place the manual PAT lands; the claude.json entry stays URL-only (#810) and
|
|
389
|
-
* the bridge resolves the bearer token from this credentials file at runtime,
|
|
390
|
-
* so the secret is never embedded in claude.json.
|
|
391
|
-
*
|
|
392
|
-
* A non-2xx / network failure returns `{ ok: false, reason }` and writes
|
|
393
|
-
* NOTHING — the caller reports the reason and exits without a half-configured
|
|
394
|
-
* install.
|
|
395
|
-
*/
|
|
396
|
-
export declare function persistManualPat(baseUrl: string, token: string): Promise<ManualPatPersistResult>;
|
|
397
323
|
export interface RunSetupInteractiveOptions extends RunSetupOptions {
|
|
398
324
|
/**
|
|
399
325
|
* Injectable OIDC-state probe for the `--remote` path (#807). Defaults to
|