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 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 to the
123
- REST API) and caches the PAT under your OS config dir. For a full
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). When set, the REMOTE bridge entry
223
- * (`wood-fired-tasks-remote`) is written instead of the local one, carrying
224
- * `WFT_API_URL` / `WFT_API_KEY`.
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
- * Override the OS CONFIG dir (testing). Defaults to the real `configDir`.
231
- * Only the PAT cache is directed here skills/claude.json follow `home`.
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 sandbox
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 device-flow counterpart to {@link runSetup}'s remote branch:
258
- * after {@link runDeviceLogin} (#806) has self-provisioned the PAT and persisted
259
- * it via the credentials writer, there is no token to embed or double-cache
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 deliberately OMITS the
262
- * `--token`-required guard and the `cachePat` step.
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