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 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 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,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, promptSecret, promptLine } from '../util/prompt.js';
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 { writeCredentials } from '../auth/credentials.js';
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 sandbox
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
- // Task #738/#810: when a remote URL is supplied, write the URL-only REMOTE
395
- // bridge entry (carrying WFT_API_URL only) under 'wood-fired-tasks-remote'
396
- // instead of the local stdio entry, and cache the PAT separately. A token is
397
- // required so a usable remote credential exists; it is NEVER persisted into
398
- // claude.json (the bridge resolves it from the cache at runtime).
399
- const isRemote = typeof options.remote === 'string' && options.remote.length > 0;
400
- let patCache;
401
- let serverName;
402
- let merge;
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: isRemote,
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 device-flow counterpart to {@link runSetup}'s remote branch:
494
- * after {@link runDeviceLogin} (#806) has self-provisioned the PAT and persisted
495
- * it via the credentials writer, there is no token to embed or double-cache
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 deliberately OMITS the
498
- * `--token`-required guard and the `cachePat` step.
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): write the URL-only remote bridge entry (WFT_API_URL only,
723
- // per the #810 contract the PAT is NEVER persisted into claude.json) and
724
- // cache the PAT separately, with NO OIDC probe and NO server round-trip, so it
725
- // succeeds even when the server is unreachable. Only a TOKENLESS `--remote`
726
- // runs the probe-driven device-flow / manual-PAT onboarding
727
- // (runRemoteOnboarding), which is for the interactive operator who has no PAT
728
- // yet. Require `remote` too so a programmatic caller passing {mode:'remote',
729
- // token} without a URL can't get a LOCAL install mislabeled as remote.
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
- const setup = runSetup(options);
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
- // below (records the method actually used so the result reflects reality).
749
+ // (records the method actually used so the result reflects reality).
858
750
  method = 'manual-pat';
859
751
  }
860
- // method === 'manual-pat' (task #809): obtain a PAT, validate it, and persist
861
- // it through the SAME credentials writer the device flow uses
862
- // (writeCredentials), then write the URL-only claude.json entry via the
863
- // shared writeRemoteMcpEntryOnly helper (#808/#810) identical entry shape to
864
- // the device-flow path; only the PAT source differs.
865
- //
866
- // PAT precedence:
867
- // 1. `--token <pat>` (explicit flag; wins).
868
- // 2. interactive promptSecret (a TTY pastes the PAT, never echoed).
869
- // 3. env `WFT_API_KEY` (DOCUMENTED non-TTY fallback — the same env
870
- // the remote bridge reads at runtime; lets CI / non-TTY callers supply
871
- // the PAT without a prompt instead of hanging).
872
- // On a non-TTY with none of the above, fail clearly (no hang).
873
- let token = options.token;
874
- if ((typeof token !== 'string' || token.length === 0) &&
875
- (options.isInteractive ?? shouldPrompt)()) {
876
- token = await promptSecret('Paste a personal access token: ', options.promptIO);
877
- }
878
- if (typeof token !== 'string' || token.length === 0) {
879
- // Non-TTY (or empty prompt) fallback: read the PAT from the documented
880
- // WFT_API_KEY env var rather than hanging on a prompt that has no TTY.
881
- const envToken = process.env['WFT_API_KEY'];
882
- if (typeof envToken === 'string' && envToken.length > 0) {
883
- token = envToken;
884
- }
885
- }
886
- if (typeof token !== 'string' || token.length === 0) {
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 writes the URL-only remote MCP entry (WFT_API_URL; the PAT is never stored in claude.json) and caches the PAT separately no OIDC probe, no server round-trip so it works offline/non-interactively. Omit --token to run the interactive device-flow / manual-PAT onboarding instead.')
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
- * newline (`\n`, with an optional preceding `\r`) is seen or the stream ends.
39
- * The trailing newline is stripped from the returned value.
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).
@@ -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
- * newline (`\n`, with an optional preceding `\r`) is seen or the stream ends.
11
- * The trailing newline is stripped from the returned value.
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
- * newline is seen (stripping a trailing `\r\n` or `\n`); if the stream ends
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
- const nlIdx = buffer.indexOf('\n');
144
- if (nlIdx !== -1) {
145
- let line = buffer.slice(0, nlIdx);
146
- if (line.endsWith('\r')) {
147
- line = line.slice(0, -1);
148
- }
149
- finish(line);
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; caches a PAT to the credentials
52
- file, which takes precedence over `API_KEY`). For scripting/CI, set `API_KEY` to
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 via the OAuth device flow. Requests a device code, surfaces a verification URL and user code, best-effort opens a browser, then polls until you approve. On success the minted Personal Access Token is written to the credentials file. The PAT value itself is never printed.
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 (`{event:"pending"}`, optional `{event:"slow_down"}`, then `{event:"logged_in"}` or `{event:"failed"}`).
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 **`wood-fired-tasks-remote`** entry to `~/.claude.json` (distinct
110
- from the local `wood-fired-tasks` entry — the two coexist) whose `env` carries
111
- `WFT_API_URL` (the base URL) and `WFT_API_KEY` (the PAT). That entry spawns the
112
- remote stdio bridge (`dist/mcp/remote/index.js`), which proxies every MCP tool
113
- call to the REST API over HTTP, so every machine sees one backlog.
114
-
115
- The PAT is also **cached under your OS config dir** (`remote-token`, mode `0600`
116
- on POSIX) operator configuration, never the data dir where the SQLite DB
117
- lives. `--remote` without `--token` is an error: a token is required so
118
- `WFT_API_KEY` can be set on the entry. Mint a per-machine PAT with
119
- `tasks db mint-token` (see [Bootstrap a PAT without a browser](#6-bootstrap-a-pat-without-a-browser-servers-ci-headless-agents))
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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wood-fired-tasks",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
4
4
  "description": "Network-wide task tracking system for Wood Fired Games",
5
5
  "keywords": [
6
6
  "task-tracker",