@thestreamcode/dsh-muse-code 0.1.1 → 0.2.0
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/AUTH_CONTRACT.md +12 -5
- package/README.md +124 -65
- package/client/client.js +151 -0
- package/cordis.patch.yml +11 -64
- package/lib/index.d.ts +168 -23
- package/lib/index.js +381 -34
- package/lib/open-browser.d.ts +10 -0
- package/lib/open-browser.js +38 -0
- package/locale/en.json +6 -0
- package/package.json +14 -3
- package/lib/auth.d.ts +0 -22
- package/lib/auth.js +0 -157
package/lib/index.d.ts
CHANGED
|
@@ -1,31 +1,176 @@
|
|
|
1
|
-
type
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
export declare const name = "dsh-muse-code";
|
|
3
|
+
export declare const inject: string[];
|
|
4
|
+
/** Web route the Settings control calls to start the sign-in. */
|
|
5
|
+
export declare const LOGIN_ROUTE = "/muse-code/login";
|
|
6
|
+
type Logger = {
|
|
7
|
+
info?(message: string): void;
|
|
8
|
+
warn?(message: string, ...rest: unknown[]): void;
|
|
9
|
+
debug?(message: string): void;
|
|
10
|
+
};
|
|
11
|
+
type CommandResult = {
|
|
12
|
+
kind: "success";
|
|
13
|
+
text?: string;
|
|
14
|
+
} | {
|
|
15
|
+
kind: "error";
|
|
16
|
+
text: string;
|
|
17
|
+
};
|
|
18
|
+
type AuthorizationMethods = Array<{
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
}>;
|
|
22
|
+
type AuthorizationEntry = {
|
|
13
23
|
key: string;
|
|
14
24
|
label: string;
|
|
15
|
-
methods:
|
|
16
|
-
|
|
17
|
-
|
|
25
|
+
methods: AuthorizationMethods;
|
|
26
|
+
inFlight: boolean;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* One notice from the authorization seam. The seam's vocabulary is
|
|
30
|
+
* `{ message, url?, code? }`; the raw pi-ai fields are accepted too so the
|
|
31
|
+
* reader also covers a direct adapter notice.
|
|
32
|
+
*/
|
|
33
|
+
type AuthorizationNotice = {
|
|
34
|
+
type?: string;
|
|
35
|
+
message?: string;
|
|
36
|
+
instructions?: string;
|
|
37
|
+
url?: string;
|
|
38
|
+
code?: string;
|
|
39
|
+
userCode?: string;
|
|
40
|
+
verificationUri?: string;
|
|
41
|
+
expiresInSeconds?: number;
|
|
42
|
+
};
|
|
43
|
+
type AuthorizationInteraction = {
|
|
44
|
+
notify(notice: AuthorizationNotice): void;
|
|
45
|
+
prompt(prompt: unknown): Promise<unknown>;
|
|
46
|
+
};
|
|
47
|
+
type AuthorizationFace = {
|
|
48
|
+
describe(key: string): AuthorizationEntry | undefined;
|
|
49
|
+
begin(request: {
|
|
50
|
+
key: string;
|
|
51
|
+
method?: string;
|
|
52
|
+
interaction: AuthorizationInteraction;
|
|
53
|
+
}): Promise<{
|
|
54
|
+
status: string;
|
|
18
55
|
}>;
|
|
19
|
-
|
|
56
|
+
cancel?(key: string): void;
|
|
57
|
+
};
|
|
58
|
+
type CommandFace = {
|
|
59
|
+
register(definition: {
|
|
60
|
+
name: string;
|
|
61
|
+
description: string;
|
|
62
|
+
handler(context: {
|
|
63
|
+
rawInput?: string;
|
|
64
|
+
}): Promise<CommandResult>;
|
|
65
|
+
}): () => void;
|
|
66
|
+
};
|
|
67
|
+
type SettingsRow = {
|
|
68
|
+
ns: string;
|
|
69
|
+
revision: number;
|
|
70
|
+
value: Record<string, unknown>;
|
|
71
|
+
};
|
|
72
|
+
type SettingsFace = {
|
|
73
|
+
describe(): SettingsRow[];
|
|
74
|
+
update(ns: string, patch: Record<string, unknown>, expectedRevision: number): Promise<void>;
|
|
75
|
+
};
|
|
76
|
+
type WebRoute = {
|
|
77
|
+
kind: "exact" | "prefix";
|
|
78
|
+
path: string;
|
|
79
|
+
handler(request: IncomingMessage, response: ServerResponse): Promise<void>;
|
|
80
|
+
};
|
|
81
|
+
type WebServerFace = {
|
|
82
|
+
register(route: WebRoute): () => void;
|
|
83
|
+
};
|
|
84
|
+
type ChildContext = HostContext & {
|
|
85
|
+
settings?: SettingsFace;
|
|
86
|
+
webServer?: WebServerFace;
|
|
20
87
|
};
|
|
21
88
|
type HostContext = {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
89
|
+
authorization: AuthorizationFace;
|
|
90
|
+
commands: CommandFace;
|
|
91
|
+
logger?: Logger;
|
|
92
|
+
effect(callback: () => () => void, label?: string): () => void;
|
|
93
|
+
inject?(dependencies: string[], callback: (child: ChildContext) => void): void;
|
|
27
94
|
};
|
|
28
|
-
|
|
29
|
-
export
|
|
95
|
+
/** Injected side effects, overridable in tests. */
|
|
96
|
+
export type SignInOptions = {
|
|
97
|
+
openBrowser?: (url: string) => boolean;
|
|
98
|
+
timeoutMs?: number;
|
|
99
|
+
};
|
|
100
|
+
/** Actionable instructions extracted from one authorization notice. */
|
|
101
|
+
export type SignInInstruction = {
|
|
102
|
+
/** Verification page as the flow reported it. */
|
|
103
|
+
url: string;
|
|
104
|
+
/** Verification page with the user code attached when the URL accepts it. */
|
|
105
|
+
completeUrl: string;
|
|
106
|
+
code?: string;
|
|
107
|
+
message?: string;
|
|
108
|
+
};
|
|
109
|
+
type SignInOutcome = {
|
|
110
|
+
ok: true;
|
|
111
|
+
text: string;
|
|
112
|
+
url: string;
|
|
113
|
+
code?: string;
|
|
114
|
+
} | {
|
|
115
|
+
ok: false;
|
|
116
|
+
reason: "unavailable" | "busy" | "timeout" | "failed" | "ended";
|
|
117
|
+
text: string;
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Register the sign-in command, the Settings > Models sign-in route, and the
|
|
121
|
+
* settings write that makes the Muse Code provider visible.
|
|
122
|
+
* @param ctx - Host plugin context.
|
|
123
|
+
*/
|
|
30
124
|
export declare function apply(ctx: HostContext): void;
|
|
125
|
+
/** Command definition for the sign-in; exported for tests. */
|
|
126
|
+
export declare function museLoginCommand(ctx: HostContext, options?: SignInOptions): Parameters<CommandFace["register"]>[0];
|
|
127
|
+
/**
|
|
128
|
+
* Build the exact Web route that starts the same sign-in from the Settings
|
|
129
|
+
* page. The route answers JSON so the Client control can render the code and
|
|
130
|
+
* the failure reason next to its button.
|
|
131
|
+
* @param ctx - Host plugin context.
|
|
132
|
+
* @param options - injected side effects, overridable in tests.
|
|
133
|
+
* @returns an HTTP handler for {@link LOGIN_ROUTE}.
|
|
134
|
+
*/
|
|
135
|
+
export declare function createLoginRouteHandler(ctx: HostContext, options?: SignInOptions): (request: IncomingMessage, response: ServerResponse) => Promise<void>;
|
|
136
|
+
/**
|
|
137
|
+
* Start one sign-in attempt and wait for the actionable device-code notice.
|
|
138
|
+
* The attempt itself keeps running after the notice, so Meta's approval is
|
|
139
|
+
* picked up and the credential is stored by the seam.
|
|
140
|
+
* @param ctx - Host plugin context.
|
|
141
|
+
* @param options - injected side effects, overridable in tests.
|
|
142
|
+
* @returns the user-facing outcome.
|
|
143
|
+
*/
|
|
144
|
+
export declare function startSignIn(ctx: HostContext, options?: SignInOptions): Promise<SignInOutcome>;
|
|
145
|
+
/**
|
|
146
|
+
* Read the actionable instruction out of one authorization notice.
|
|
147
|
+
* @param notice - a seam notice (`message`/`url`/`code`) or a raw adapter one.
|
|
148
|
+
* @returns the instruction, or undefined for progress notices with no page.
|
|
149
|
+
*/
|
|
150
|
+
export declare function readSignInNotice(notice: AuthorizationNotice): SignInInstruction | undefined;
|
|
151
|
+
/**
|
|
152
|
+
* Render the instruction text shared by the command result and the Settings
|
|
153
|
+
* control.
|
|
154
|
+
* @param instruction - actionable notice.
|
|
155
|
+
* @param opened - whether the browser hand-off was started.
|
|
156
|
+
* @returns one line the user can act on.
|
|
157
|
+
*/
|
|
158
|
+
export declare function formatSignInText(instruction: SignInInstruction, opened: boolean): string;
|
|
159
|
+
/**
|
|
160
|
+
* Declare the shipped `meta` route in the `llm-pi-ai` settings section so the
|
|
161
|
+
* provider appears in Settings > Models and its models in the picker.
|
|
162
|
+
*
|
|
163
|
+
* The write is a merge into the entry's editable config, exactly what the
|
|
164
|
+
* Models page does when a user adds this provider by hand; an already declared
|
|
165
|
+
* `meta` profile is never touched.
|
|
166
|
+
*
|
|
167
|
+
* @param settings - the Settings form service.
|
|
168
|
+
* @param logger - optional plugin logger.
|
|
169
|
+
* @param options - retry window for late-activating profile entries.
|
|
170
|
+
* @returns whether this call added the route.
|
|
171
|
+
*/
|
|
172
|
+
export declare function ensureMuseRoute(settings: SettingsFace, logger?: Logger, options?: {
|
|
173
|
+
attempts?: number;
|
|
174
|
+
delayMs?: number;
|
|
175
|
+
}): Promise<boolean>;
|
|
31
176
|
export {};
|
package/lib/index.js
CHANGED
|
@@ -1,40 +1,387 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Muse Code (Meta subscription) integration for DeepSeek Harness.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
3
|
+
// DeepSeek Harness already serves this subscription: the shipped pi-ai catalog
|
|
4
|
+
// exposes the `meta` route (api.meta.ai/v1) and registers the `llm-pi-ai/meta`
|
|
5
|
+
// authorization flow, which runs Meta's device-code login and stores the
|
|
6
|
+
// renewable credential record. The Web/Desktop Settings surface has no generic
|
|
7
|
+
// button for provider OAuth flows, so this bundle adds the pieces a user needs:
|
|
8
|
+
//
|
|
9
|
+
// - it declares the `meta` route in the `llm-pi-ai` settings section, so the
|
|
10
|
+
// provider appears under Settings > Models and its models in the model picker;
|
|
11
|
+
// - it exposes the shipped sign-in as the `/muse-login` command;
|
|
12
|
+
// - it registers an exact Web route (`/muse-code/login`) and a Sign in control
|
|
13
|
+
// under Settings > Models that calls it, because the Settings page cannot
|
|
14
|
+
// reach the Host authorization seam through the Remote API.
|
|
15
|
+
//
|
|
16
|
+
// The Host opens the verification page in the user's default browser itself; the
|
|
17
|
+
// route and the command also return the code and the URL, so a blocked browser
|
|
18
|
+
// never strands the sign-in.
|
|
19
|
+
//
|
|
20
|
+
// A second `@deepseek-ai/dsh-llm-pi-ai` instance is not an option: every
|
|
21
|
+
// instance declares the whole installed provider catalog, so the extra instance
|
|
22
|
+
// fails to activate with a duplicate configurable-provider error.
|
|
23
|
+
import { openExternal } from "./open-browser.js";
|
|
9
24
|
export const name = "dsh-muse-code";
|
|
10
|
-
export const inject = ["
|
|
11
|
-
|
|
12
|
-
const
|
|
25
|
+
export const inject = ["authorization", "commands"];
|
|
26
|
+
/** Settings namespace whose provider profiles configure the pi-ai routes. */
|
|
27
|
+
const SETTINGS_NS = "llm-pi-ai";
|
|
28
|
+
/** Route served by the shipped pi-ai Meta provider. */
|
|
29
|
+
const PROVIDER = "meta";
|
|
30
|
+
/** Credential record / authorization flow the shipped pi-ai adapter registers. */
|
|
31
|
+
const AUTH_KEY = "llm-pi-ai/meta";
|
|
32
|
+
/** Preferred sign-in method; the shipped flow offers `oauth` first. */
|
|
33
|
+
const AUTH_METHOD = "oauth";
|
|
34
|
+
/** Display name for the route this bundle declares. */
|
|
35
|
+
const ROUTE_LABEL = "Muse Code (Meta subscription)";
|
|
36
|
+
/** Web route the Settings control calls to start the sign-in. */
|
|
37
|
+
export const LOGIN_ROUTE = "/muse-code/login";
|
|
38
|
+
/** How long the sign-in waits for the device-code notice before giving up. */
|
|
39
|
+
const NOTICE_TIMEOUT_MS = 20_000;
|
|
40
|
+
/**
|
|
41
|
+
* Register the sign-in command, the Settings > Models sign-in route, and the
|
|
42
|
+
* settings write that makes the Muse Code provider visible.
|
|
43
|
+
* @param ctx - Host plugin context.
|
|
44
|
+
*/
|
|
13
45
|
export function apply(ctx) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
46
|
+
ctx.effect(() => ctx.commands.register(museLoginCommand(ctx)), "dsh-muse-code: /muse-login");
|
|
47
|
+
if (typeof ctx.inject !== "function")
|
|
48
|
+
return;
|
|
49
|
+
ctx.inject(["webServer"], (child) => {
|
|
50
|
+
const webServer = child.webServer;
|
|
51
|
+
if (webServer === undefined)
|
|
52
|
+
return;
|
|
53
|
+
child.effect(() => webServer.register({
|
|
54
|
+
kind: "exact",
|
|
55
|
+
path: LOGIN_ROUTE,
|
|
56
|
+
handler: createLoginRouteHandler(ctx),
|
|
57
|
+
}), "dsh-muse-code: login route");
|
|
58
|
+
});
|
|
59
|
+
ctx.inject(["settings"], (child) => {
|
|
60
|
+
child.effect(() => {
|
|
61
|
+
const settings = child.settings;
|
|
62
|
+
if (settings === undefined)
|
|
63
|
+
return () => { };
|
|
64
|
+
void ensureMuseRoute(settings, ctx.logger).catch((error) => {
|
|
65
|
+
warn(ctx.logger, "dsh-muse-code: could not declare the Muse Code route", error);
|
|
66
|
+
});
|
|
67
|
+
return () => { };
|
|
68
|
+
}, "dsh-muse-code: declare route");
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/** Command definition for the sign-in; exported for tests. */
|
|
72
|
+
export function museLoginCommand(ctx, options = {}) {
|
|
73
|
+
return {
|
|
74
|
+
name: "muse-login",
|
|
75
|
+
description: "Sign in to the Muse Code (Meta) subscription and enable its models",
|
|
76
|
+
async handler() {
|
|
77
|
+
const outcome = await startSignIn(ctx, options);
|
|
78
|
+
if (outcome.ok)
|
|
79
|
+
return { kind: "success", text: outcome.text };
|
|
80
|
+
return { kind: "error", text: outcome.text };
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Build the exact Web route that starts the same sign-in from the Settings
|
|
86
|
+
* page. The route answers JSON so the Client control can render the code and
|
|
87
|
+
* the failure reason next to its button.
|
|
88
|
+
* @param ctx - Host plugin context.
|
|
89
|
+
* @param options - injected side effects, overridable in tests.
|
|
90
|
+
* @returns an HTTP handler for {@link LOGIN_ROUTE}.
|
|
91
|
+
*/
|
|
92
|
+
export function createLoginRouteHandler(ctx, options = {}) {
|
|
93
|
+
return async (request, response) => {
|
|
94
|
+
setCorsHeaders(response);
|
|
95
|
+
if (request.method === "OPTIONS") {
|
|
96
|
+
response.writeHead(204);
|
|
97
|
+
response.end();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (request.method === "GET") {
|
|
101
|
+
// A plain navigation: this tab is the browser, so the flow need not open
|
|
102
|
+
// another one. The page shows the code and moves on to Meta's page.
|
|
103
|
+
const outcome = await startSignIn(ctx, { ...options, openBrowser: () => false });
|
|
104
|
+
sendHtml(response, signInPage(outcome));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (request.method !== "POST") {
|
|
108
|
+
sendJson(response, 405, { ok: false, reason: "method", text: "Use GET or POST to start the sign-in." });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const outcome = await startSignIn(ctx, options);
|
|
112
|
+
if (outcome.ok) {
|
|
113
|
+
sendJson(response, 200, { ok: true, text: outcome.text, url: outcome.url, code: outcome.code });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const status = outcome.reason === "busy" ? 409 : outcome.reason === "timeout" ? 504 : 500;
|
|
117
|
+
sendJson(response, status, { ok: false, reason: outcome.reason, text: outcome.text });
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Start one sign-in attempt and wait for the actionable device-code notice.
|
|
122
|
+
* The attempt itself keeps running after the notice, so Meta's approval is
|
|
123
|
+
* picked up and the credential is stored by the seam.
|
|
124
|
+
* @param ctx - Host plugin context.
|
|
125
|
+
* @param options - injected side effects, overridable in tests.
|
|
126
|
+
* @returns the user-facing outcome.
|
|
127
|
+
*/
|
|
128
|
+
export async function startSignIn(ctx, options = {}) {
|
|
129
|
+
const entry = ctx.authorization.describe(AUTH_KEY);
|
|
130
|
+
if (entry === undefined) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
reason: "unavailable",
|
|
134
|
+
text: "Muse Code is unavailable: this DeepSeek Harness build has no Meta sign-in flow " +
|
|
135
|
+
`(${AUTH_KEY}). Update DeepSeek Harness or add the Meta provider under Settings > Models first.`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (entry.inFlight) {
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
reason: "busy",
|
|
142
|
+
text: "A Muse Code sign-in is already running. Approve it in the browser, then retry.",
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const method = entry.methods.find((candidate) => candidate.id === AUTH_METHOD)?.id ?? entry.methods[0]?.id;
|
|
146
|
+
if (method === undefined) {
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
reason: "unavailable",
|
|
150
|
+
text: `The Muse Code sign-in flow offers no method to run (${AUTH_KEY}).`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const openBrowser = options.openBrowser ?? openExternal;
|
|
154
|
+
const timeoutMs = options.timeoutMs ?? NOTICE_TIMEOUT_MS;
|
|
155
|
+
let announced;
|
|
156
|
+
let announce = () => { };
|
|
157
|
+
const notice = new Promise((resolve) => {
|
|
158
|
+
announce = resolve;
|
|
159
|
+
});
|
|
160
|
+
const attempt = ctx.authorization.begin({
|
|
161
|
+
key: AUTH_KEY,
|
|
162
|
+
method,
|
|
163
|
+
interaction: {
|
|
164
|
+
notify(value) {
|
|
165
|
+
const instruction = readSignInNotice(value);
|
|
166
|
+
if (instruction === undefined)
|
|
167
|
+
return;
|
|
168
|
+
announced = instruction;
|
|
169
|
+
announce(instruction);
|
|
170
|
+
},
|
|
171
|
+
prompt() {
|
|
172
|
+
return Promise.reject(new Error("Muse Code sign-in does not accept interactive prompts"));
|
|
173
|
+
},
|
|
33
174
|
},
|
|
175
|
+
});
|
|
176
|
+
// The attempt outlives the request that started it: Meta approval arrives
|
|
177
|
+
// later and the seam stores the credential then.
|
|
178
|
+
attempt.then((outcome) => ctx.logger?.info?.(`dsh-muse-code: sign-in settled as ${outcome.status}`), (failure) => warn(ctx.logger, "dsh-muse-code: sign-in failed", failure));
|
|
179
|
+
const announcedNotice = notice.then((instruction) => ({ kind: "notice", instruction }));
|
|
180
|
+
const settled = attempt.then((outcome) => ({ kind: "settled", outcome }), (failure) => ({ kind: "failed", failure }));
|
|
181
|
+
const timer = {};
|
|
182
|
+
const timeout = new Promise((resolve) => {
|
|
183
|
+
timer.handle = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
|
|
184
|
+
timer.handle.unref?.();
|
|
185
|
+
});
|
|
186
|
+
try {
|
|
187
|
+
const outcome = await Promise.race([announcedNotice, settled, timeout]);
|
|
188
|
+
if (outcome.kind === "notice") {
|
|
189
|
+
return successOutcome(outcome.instruction, openBrowser);
|
|
190
|
+
}
|
|
191
|
+
if (outcome.kind === "timeout") {
|
|
192
|
+
ctx.authorization.cancel?.(AUTH_KEY);
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
reason: "timeout",
|
|
196
|
+
text: "Muse Code sign-in did not answer in time. Check the connection and run /muse-login again.",
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
if (outcome.kind === "failed") {
|
|
200
|
+
const text = messageOf(outcome.failure);
|
|
201
|
+
if (/already running/i.test(text)) {
|
|
202
|
+
return { ok: false, reason: "busy", text: "A Muse Code sign-in is already running. Approve it in the browser, then retry." };
|
|
203
|
+
}
|
|
204
|
+
return { ok: false, reason: "failed", text: `Muse Code sign-in failed: ${text}` };
|
|
205
|
+
}
|
|
206
|
+
if (announced !== undefined)
|
|
207
|
+
return successOutcome(announced, openBrowser);
|
|
208
|
+
if (outcome.outcome.status === "authorized") {
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
reason: "ended",
|
|
212
|
+
text: "Muse Code sign-in finished without showing a code. Run /muse-login again if the models stay unavailable.",
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return { ok: false, reason: "ended", text: `Muse Code sign-in ended (${outcome.outcome.status}).` };
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
if (timer.handle !== undefined)
|
|
219
|
+
clearTimeout(timer.handle);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function successOutcome(instruction, openBrowser) {
|
|
223
|
+
// The browser hand-off is a courtesy for a purely local effect: the user must
|
|
224
|
+
// still confirm the code on Meta's page, and both surfaces expose the URL.
|
|
225
|
+
const opened = openBrowser(instruction.completeUrl);
|
|
226
|
+
return {
|
|
227
|
+
ok: true,
|
|
228
|
+
url: instruction.completeUrl,
|
|
229
|
+
...(instruction.code === undefined ? {} : { code: instruction.code }),
|
|
230
|
+
text: formatSignInText(instruction, opened),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Read the actionable instruction out of one authorization notice.
|
|
235
|
+
* @param notice - a seam notice (`message`/`url`/`code`) or a raw adapter one.
|
|
236
|
+
* @returns the instruction, or undefined for progress notices with no page.
|
|
237
|
+
*/
|
|
238
|
+
export function readSignInNotice(notice) {
|
|
239
|
+
const rawUrl = firstNonEmptyString(notice.url, notice.verificationUri);
|
|
240
|
+
if (rawUrl === undefined)
|
|
241
|
+
return undefined;
|
|
242
|
+
let url;
|
|
243
|
+
try {
|
|
244
|
+
url = new URL(rawUrl);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
250
|
+
return undefined;
|
|
251
|
+
const code = firstNonEmptyString(notice.code, notice.userCode);
|
|
252
|
+
const message = firstNonEmptyString(notice.message, notice.instructions);
|
|
253
|
+
if (code !== undefined && !url.searchParams.has("code"))
|
|
254
|
+
url.searchParams.set("code", code);
|
|
255
|
+
return {
|
|
256
|
+
url: rawUrl,
|
|
257
|
+
completeUrl: url.toString(),
|
|
258
|
+
...(code === undefined ? {} : { code }),
|
|
259
|
+
...(message === undefined ? {} : { message }),
|
|
34
260
|
};
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Render the instruction text shared by the command result and the Settings
|
|
264
|
+
* control.
|
|
265
|
+
* @param instruction - actionable notice.
|
|
266
|
+
* @param opened - whether the browser hand-off was started.
|
|
267
|
+
* @returns one line the user can act on.
|
|
268
|
+
*/
|
|
269
|
+
export function formatSignInText(instruction, opened) {
|
|
270
|
+
const openPart = opened
|
|
271
|
+
? "A browser tab should have opened; if not, open it yourself."
|
|
272
|
+
: "Open this page in your browser.";
|
|
273
|
+
const codePart = instruction.code === undefined
|
|
274
|
+
? "Confirm the sign-in on the Meta page."
|
|
275
|
+
: `Confirm the code ${instruction.code} there.`;
|
|
276
|
+
return `Muse Code sign-in started. ${openPart} ${codePart} Page: ${instruction.completeUrl} Muse Code models become available as soon as Meta confirms.`;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Declare the shipped `meta` route in the `llm-pi-ai` settings section so the
|
|
280
|
+
* provider appears in Settings > Models and its models in the picker.
|
|
281
|
+
*
|
|
282
|
+
* The write is a merge into the entry's editable config, exactly what the
|
|
283
|
+
* Models page does when a user adds this provider by hand; an already declared
|
|
284
|
+
* `meta` profile is never touched.
|
|
285
|
+
*
|
|
286
|
+
* @param settings - the Settings form service.
|
|
287
|
+
* @param logger - optional plugin logger.
|
|
288
|
+
* @param options - retry window for late-activating profile entries.
|
|
289
|
+
* @returns whether this call added the route.
|
|
290
|
+
*/
|
|
291
|
+
export async function ensureMuseRoute(settings, logger, options = {}) {
|
|
292
|
+
const attempts = options.attempts ?? 12;
|
|
293
|
+
const delayMs = options.delayMs ?? 250;
|
|
294
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
295
|
+
const row = settings.describe().find((candidate) => candidate.ns === SETTINGS_NS);
|
|
296
|
+
if (row !== undefined) {
|
|
297
|
+
const providers = row.value["providers"];
|
|
298
|
+
const declared = isRecord(providers) && Object.hasOwn(providers, PROVIDER);
|
|
299
|
+
if (declared)
|
|
300
|
+
return false;
|
|
301
|
+
await settings.update(SETTINGS_NS, { providers: { [PROVIDER]: { displayName: ROUTE_LABEL } } }, row.revision);
|
|
302
|
+
logger?.info?.(`dsh-muse-code: declared the ${PROVIDER} route in Settings > Models`);
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
if (attempt < attempts - 1 && delayMs > 0)
|
|
306
|
+
await sleep(delayMs);
|
|
307
|
+
}
|
|
308
|
+
logger?.debug?.(`dsh-muse-code: no configurable "${SETTINGS_NS}" entry; leaving the route to Settings > Models`);
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
function setCorsHeaders(response) {
|
|
312
|
+
response.setHeader("Access-Control-Allow-Origin", "*");
|
|
313
|
+
response.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
314
|
+
response.setHeader("Access-Control-Allow-Headers", "content-type");
|
|
315
|
+
response.setHeader("Cache-Control", "no-store");
|
|
316
|
+
}
|
|
317
|
+
function sendJson(response, status, body) {
|
|
318
|
+
const text = JSON.stringify(body);
|
|
319
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
320
|
+
response.end(text);
|
|
321
|
+
}
|
|
322
|
+
function sendHtml(response, html) {
|
|
323
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
324
|
+
response.end(html);
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* The page a plain navigation to {@link LOGIN_ROUTE} receives: the code, the
|
|
328
|
+
* fallback link, and an immediate move to Meta's verification page.
|
|
329
|
+
* @param outcome - result of the sign-in attempt.
|
|
330
|
+
* @returns a self-contained HTML document.
|
|
331
|
+
*/
|
|
332
|
+
function signInPage(outcome) {
|
|
333
|
+
const title = "Muse Code sign-in";
|
|
334
|
+
if (!outcome.ok) {
|
|
335
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head>` +
|
|
336
|
+
`<body style="font-family:system-ui,sans-serif;padding:2rem;line-height:1.5">` +
|
|
337
|
+
`<h1 style="font-size:1.1rem">${escapeHtml(title)}</h1>` +
|
|
338
|
+
`<p>${escapeHtml(outcome.text)}</p>` +
|
|
339
|
+
`<p><a href="/muse-code/login">Try again</a></p></body></html>`;
|
|
340
|
+
}
|
|
341
|
+
const codePart = outcome.code === undefined
|
|
342
|
+
? ""
|
|
343
|
+
: `<p style="font-size:1.4rem;letter-spacing:0.1em"><strong>${escapeHtml(outcome.code)}</strong></p>`;
|
|
344
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title>` +
|
|
345
|
+
`<meta http-equiv="refresh" content="1; url=${escapeHtml(outcome.url)}"></head>` +
|
|
346
|
+
`<body style="font-family:system-ui,sans-serif;padding:2rem;line-height:1.5">` +
|
|
347
|
+
`<h1 style="font-size:1.1rem">${escapeHtml(title)}</h1>` +
|
|
348
|
+
`<p>Confirm this code on Meta's page:</p>${codePart}` +
|
|
349
|
+
`<p>If the page does not open, <a href="${escapeHtml(outcome.url)}">continue on Meta</a>.</p>` +
|
|
350
|
+
`</body></html>`;
|
|
351
|
+
}
|
|
352
|
+
function escapeHtml(value) {
|
|
353
|
+
return value
|
|
354
|
+
.replaceAll("&", "&")
|
|
355
|
+
.replaceAll("<", "<")
|
|
356
|
+
.replaceAll(">", ">")
|
|
357
|
+
.replaceAll('"', """);
|
|
358
|
+
}
|
|
359
|
+
function firstNonEmptyString(...values) {
|
|
360
|
+
for (const value of values) {
|
|
361
|
+
if (typeof value === "string" && value.trim().length > 0)
|
|
362
|
+
return value.trim();
|
|
363
|
+
}
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
function isRecord(value) {
|
|
367
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
368
|
+
}
|
|
369
|
+
function messageOf(failure) {
|
|
370
|
+
if (failure instanceof Error && failure.message.length > 0)
|
|
371
|
+
return failure.message;
|
|
372
|
+
const text = String(failure);
|
|
373
|
+
return text.length > 0 ? text : "unknown error";
|
|
374
|
+
}
|
|
375
|
+
function warn(logger, message, error) {
|
|
376
|
+
const text = `${message}: ${messageOf(error)}`;
|
|
377
|
+
if (logger?.warn !== undefined)
|
|
378
|
+
logger.warn(text);
|
|
379
|
+
else if (logger?.debug !== undefined)
|
|
380
|
+
logger.debug(text);
|
|
381
|
+
}
|
|
382
|
+
function sleep(ms) {
|
|
383
|
+
return new Promise((resolve) => {
|
|
384
|
+
const handle = setTimeout(resolve, ms);
|
|
385
|
+
handle.unref?.();
|
|
386
|
+
});
|
|
40
387
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
type SpawnLike = typeof spawn;
|
|
3
|
+
/**
|
|
4
|
+
* Hand one http(s) URL to the platform's default browser.
|
|
5
|
+
* @param url - absolute URL to open.
|
|
6
|
+
* @param spawnImpl - injectable process spawner, for tests.
|
|
7
|
+
* @returns whether the hand-off was started; it does not prove a page opened.
|
|
8
|
+
*/
|
|
9
|
+
export declare function openExternal(url: string, spawnImpl?: SpawnLike): boolean;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Best-effort "open this URL in the user's default browser" for the host side.
|
|
2
|
+
//
|
|
3
|
+
// DeepSeek Harness has no provider-neutral host service for opening an external
|
|
4
|
+
// page; its account flow delegates that to the Desktop shell. The sign-in needs
|
|
5
|
+
// the Meta verification page in a browser, so this module hands the URL to the
|
|
6
|
+
// platform's own URI handler without a shell: `rundll32 url.dll,FileProtocolHandler`
|
|
7
|
+
// on Windows, `open` on macOS, `xdg-open` elsewhere.
|
|
8
|
+
//
|
|
9
|
+
// Failure is never fatal: the command and the login route both return the URL
|
|
10
|
+
// as text so the user can open it manually.
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
/**
|
|
13
|
+
* Hand one http(s) URL to the platform's default browser.
|
|
14
|
+
* @param url - absolute URL to open.
|
|
15
|
+
* @param spawnImpl - injectable process spawner, for tests.
|
|
16
|
+
* @returns whether the hand-off was started; it does not prove a page opened.
|
|
17
|
+
*/
|
|
18
|
+
export function openExternal(url, spawnImpl = spawn) {
|
|
19
|
+
if (!/^https?:\/\//i.test(url))
|
|
20
|
+
return false;
|
|
21
|
+
const [command, args] = openerCommand(url);
|
|
22
|
+
try {
|
|
23
|
+
const child = spawnImpl(command, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
24
|
+
child.on("error", () => { });
|
|
25
|
+
child.unref();
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function openerCommand(url) {
|
|
33
|
+
if (process.platform === "win32")
|
|
34
|
+
return ["rundll32.exe", ["url.dll,FileProtocolHandler", url]];
|
|
35
|
+
if (process.platform === "darwin")
|
|
36
|
+
return ["open", [url]];
|
|
37
|
+
return ["xdg-open", [url]];
|
|
38
|
+
}
|