linkshell-cli 0.10.93 → 0.10.94
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/README.md +4 -4
- package/dist/cli/src/commands/desktop.d.ts +2 -0
- package/dist/cli/src/commands/desktop.js +2 -46
- package/dist/cli/src/commands/desktop.js.map +1 -1
- package/dist/cli/src/commands/host.d.ts +2 -2
- package/dist/cli/src/commands/host.js +3 -3
- package/dist/cli/src/commands/host.js.map +1 -1
- package/dist/cli/src/commands/login.js +1 -1
- package/dist/cli/src/commands/login.js.map +1 -1
- package/dist/cli/src/commands/setup.js +1 -1
- package/dist/cli/src/commands/setup.js.map +1 -1
- package/dist/cli/src/commands/sign-in.d.ts +2 -0
- package/dist/cli/src/commands/sign-in.js +4 -6
- package/dist/cli/src/commands/sign-in.js.map +1 -1
- package/dist/cli/src/pro-membership.d.ts +8 -8
- package/dist/cli/src/pro-membership.js +21 -27
- package/dist/cli/src/pro-membership.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/client-core/src/pro-membership.d.ts +37 -9
- package/dist/client-core/src/pro-membership.js +98 -70
- package/dist/client-core/src/pro-membership.js.map +1 -1
- package/dist/gateway/src/subscription.d.ts +4 -1
- package/dist/gateway/src/subscription.js +25 -13
- package/dist/gateway/src/subscription.js.map +1 -1
- package/package.json +3 -3
- package/src/commands/desktop.ts +2 -40
- package/src/commands/host.ts +3 -3
- package/src/commands/login.ts +1 -1
- package/src/commands/setup.ts +1 -1
- package/src/commands/sign-in.ts +6 -6
- package/src/pro-membership.ts +23 -23
|
@@ -1,18 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkShell is part of the iTool membership: the monthly tier (iTool site + LinkShell) and the
|
|
3
|
+
* yearly tier (every iTool product) both include it. Membership is bought on itool.tech only;
|
|
4
|
+
* clients just read the entitlement and send people to the buy page.
|
|
5
|
+
*/
|
|
6
|
+
export type MembershipTier = "monthly" | "yearly";
|
|
1
7
|
export interface ProMembership {
|
|
8
|
+
/** "pro": this account may use LinkShell's paid parts (the official gateway) right now. */
|
|
2
9
|
plan: "free" | "pro";
|
|
10
|
+
/** Which iTool membership grants it; absent when `plan` is "free". */
|
|
11
|
+
tier?: MembershipTier;
|
|
12
|
+
/** When LinkShell's entitlement ends; absent when `plan` is "free". */
|
|
3
13
|
expiresAt?: string;
|
|
4
14
|
}
|
|
5
15
|
export interface ProMembershipOptions {
|
|
6
|
-
supabaseUrl: string;
|
|
7
|
-
anonKey: string;
|
|
8
16
|
accessToken: string;
|
|
9
|
-
|
|
17
|
+
/** Kept for callers that pass it; the entitlement is read for the token's own user. */
|
|
18
|
+
userId?: string;
|
|
19
|
+
/** With both, the Supabase RPC is tried when itool.tech can't be reached. */
|
|
20
|
+
supabaseUrl?: string;
|
|
21
|
+
anonKey?: string;
|
|
22
|
+
/** "supabase" skips itool.tech; "itool" (the default) prefers it. */
|
|
23
|
+
prefer?: "itool" | "supabase";
|
|
10
24
|
signal?: AbortSignal;
|
|
11
25
|
}
|
|
12
|
-
export declare const
|
|
13
|
-
/** The
|
|
26
|
+
export declare const LINKSHELL_PRODUCT = "linkshell";
|
|
27
|
+
/** The only place a membership is bought. Phones must not link to it (App Store rules). */
|
|
28
|
+
export declare const ITOOL_MEMBERSHIP_URL = "https://itool.tech/pro?from=linkshell";
|
|
29
|
+
export declare const ITOOL_ENTITLEMENT_URL = "https://itool.tech/api/membership/entitlement?product=linkshell";
|
|
30
|
+
/**
|
|
31
|
+
* Why a lookup failed. `login` means the token is no good; `unavailable` and `network` say
|
|
32
|
+
* nothing about the account, so callers keep what they last knew.
|
|
33
|
+
*/
|
|
34
|
+
export declare class MembershipError extends Error {
|
|
35
|
+
readonly kind: "login" | "unavailable" | "network" | "malformed";
|
|
36
|
+
constructor(message: string, kind: "login" | "unavailable" | "network" | "malformed");
|
|
37
|
+
}
|
|
38
|
+
export declare function membershipTierLabel(tier: MembershipTier | undefined): string;
|
|
39
|
+
/**
|
|
40
|
+
* The result of `itool_entitlement('linkshell')`, through either route, as LinkShell shows it.
|
|
41
|
+
* `active` is the server's answer; an expiry already past the server's clock still reads as
|
|
42
|
+
* free so a stale row can't grant anything.
|
|
43
|
+
*/
|
|
44
|
+
export declare function membershipFromEntitlement(value: unknown): ProMembership;
|
|
45
|
+
/** The entitlement is read with the user's own token; only the server can grant it. */
|
|
14
46
|
export declare function fetchProMembership(options: ProMembershipOptions): Promise<ProMembership>;
|
|
15
|
-
export declare function redeemProCode(accessToken: string, code: string): Promise<{
|
|
16
|
-
expiresAt: string;
|
|
17
|
-
idempotent: boolean;
|
|
18
|
-
}>;
|
|
@@ -1,13 +1,66 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* LinkShell is part of the iTool membership: the monthly tier (iTool site + LinkShell) and the
|
|
3
|
+
* yearly tier (every iTool product) both include it. Membership is bought on itool.tech only;
|
|
4
|
+
* clients just read the entitlement and send people to the buy page.
|
|
5
|
+
*/
|
|
6
|
+
export const LINKSHELL_PRODUCT = "linkshell";
|
|
7
|
+
/** The only place a membership is bought. Phones must not link to it (App Store rules). */
|
|
8
|
+
export const ITOOL_MEMBERSHIP_URL = "https://itool.tech/pro?from=linkshell";
|
|
9
|
+
export const ITOOL_ENTITLEMENT_URL = `https://itool.tech/api/membership/entitlement?product=${LINKSHELL_PRODUCT}`;
|
|
3
10
|
const REQUEST_TIMEOUT_MS = 15_000;
|
|
4
11
|
const LOGIN_REQUIRED = "登录已失效,请重新登录后再试";
|
|
12
|
+
const MALFORMED = "会员状态返回异常,请稍后重试";
|
|
13
|
+
/**
|
|
14
|
+
* Why a lookup failed. `login` means the token is no good; `unavailable` and `network` say
|
|
15
|
+
* nothing about the account, so callers keep what they last knew.
|
|
16
|
+
*/
|
|
17
|
+
export class MembershipError extends Error {
|
|
18
|
+
kind;
|
|
19
|
+
constructor(message, kind) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.kind = kind;
|
|
22
|
+
this.name = "MembershipError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function membershipTierLabel(tier) {
|
|
26
|
+
return tier === "yearly" ? "iTool 年度会员" : tier === "monthly" ? "iTool 月度会员" : "iTool 会员";
|
|
27
|
+
}
|
|
5
28
|
function record(value) {
|
|
6
29
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
7
30
|
}
|
|
8
31
|
function timestamp(value) {
|
|
9
32
|
return typeof value === "string" ? Date.parse(value) : NaN;
|
|
10
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* The result of `itool_entitlement('linkshell')`, through either route, as LinkShell shows it.
|
|
36
|
+
* `active` is the server's answer; an expiry already past the server's clock still reads as
|
|
37
|
+
* free so a stale row can't grant anything.
|
|
38
|
+
*/
|
|
39
|
+
export function membershipFromEntitlement(value) {
|
|
40
|
+
const body = record(value);
|
|
41
|
+
if (!body)
|
|
42
|
+
throw new MembershipError(MALFORMED, "malformed");
|
|
43
|
+
if (body.ok !== true) {
|
|
44
|
+
if (body.code === "login_required")
|
|
45
|
+
throw new MembershipError(LOGIN_REQUIRED, "login");
|
|
46
|
+
throw new MembershipError(MALFORMED, "malformed");
|
|
47
|
+
}
|
|
48
|
+
if (body.product !== undefined && body.product !== LINKSHELL_PRODUCT)
|
|
49
|
+
throw new MembershipError(MALFORMED, "malformed");
|
|
50
|
+
if (typeof body.active !== "boolean")
|
|
51
|
+
throw new MembershipError(MALFORMED, "malformed");
|
|
52
|
+
if (!body.active)
|
|
53
|
+
return { plan: "free" };
|
|
54
|
+
const until = timestamp(body.valid_until);
|
|
55
|
+
if (!Number.isFinite(until))
|
|
56
|
+
throw new MembershipError(MALFORMED, "malformed");
|
|
57
|
+
const serverTime = timestamp(body.server_time);
|
|
58
|
+
if (until <= (Number.isFinite(serverTime) ? serverTime : Date.now()))
|
|
59
|
+
return { plan: "free" };
|
|
60
|
+
// An active entitlement always comes from a paid tier; monthly is the least it can be.
|
|
61
|
+
const tier = body.tier === "yearly" ? "yearly" : "monthly";
|
|
62
|
+
return { plan: "pro", tier, expiresAt: body.valid_until };
|
|
63
|
+
}
|
|
11
64
|
async function request(url, init) {
|
|
12
65
|
const controller = new AbortController();
|
|
13
66
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
@@ -25,85 +78,60 @@ async function request(url, init) {
|
|
|
25
78
|
return { response, body };
|
|
26
79
|
}
|
|
27
80
|
catch {
|
|
28
|
-
throw new
|
|
81
|
+
throw new MembershipError(controller.signal.aborted ? "请求超时,请稍后重试" : "网络连接失败,请检查网络后重试", "network");
|
|
29
82
|
}
|
|
30
83
|
finally {
|
|
31
84
|
clearTimeout(timer);
|
|
32
85
|
}
|
|
33
86
|
}
|
|
34
|
-
|
|
35
|
-
export async function fetchProMembership(options) {
|
|
36
|
-
if (!options.accessToken.trim() || !options.userId.trim())
|
|
37
|
-
throw new Error(LOGIN_REQUIRED);
|
|
38
|
-
const url = `${options.supabaseUrl.replace(/\/$/, "")}/rest/v1/profiles?select=plan,plan_expires_at&id=eq.${encodeURIComponent(options.userId)}&limit=1`;
|
|
39
|
-
const { response, body } = await request(url, {
|
|
40
|
-
headers: { apikey: options.anonKey, Authorization: `Bearer ${options.accessToken}` },
|
|
41
|
-
signal: options.signal,
|
|
42
|
-
});
|
|
87
|
+
function checkStatus(response) {
|
|
43
88
|
if (response.status === 401 || response.status === 403)
|
|
44
|
-
throw new
|
|
89
|
+
throw new MembershipError(LOGIN_REQUIRED, "login");
|
|
90
|
+
if (response.status === 502 || response.status === 503)
|
|
91
|
+
throw new MembershipError("会员服务暂时不可用,请稍后重试", "unavailable");
|
|
45
92
|
if (!response.ok)
|
|
46
|
-
throw new
|
|
47
|
-
if (!Array.isArray(body))
|
|
48
|
-
throw new Error("会员状态返回异常,请稍后重试");
|
|
49
|
-
if (body.length === 0)
|
|
50
|
-
return { plan: "free" };
|
|
51
|
-
const profile = record(body[0]);
|
|
52
|
-
if (!profile || typeof profile.plan !== "string")
|
|
53
|
-
throw new Error("会员状态返回异常,请稍后重试");
|
|
54
|
-
const expiresAt = profile.plan_expires_at;
|
|
55
|
-
const expires = timestamp(expiresAt);
|
|
56
|
-
const serverTime = timestamp(response.headers.get("date"));
|
|
57
|
-
const now = Number.isFinite(serverTime) ? serverTime : Date.now();
|
|
58
|
-
return profile.plan === "pro" && Number.isFinite(expires) && expires > now
|
|
59
|
-
? { plan: "pro", expiresAt: expiresAt }
|
|
60
|
-
: { plan: "free" };
|
|
93
|
+
throw new MembershipError("暂时无法获取会员状态,请稍后重试", "unavailable");
|
|
61
94
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
413: "request_too_large",
|
|
74
|
-
422: "invalid_or_unavailable",
|
|
75
|
-
429: "rate_limited",
|
|
76
|
-
503: "temporarily_unavailable",
|
|
77
|
-
};
|
|
78
|
-
export async function redeemProCode(accessToken, code) {
|
|
79
|
-
if (!accessToken.trim())
|
|
80
|
-
throw new Error(LOGIN_REQUIRED);
|
|
81
|
-
const normalizedCode = code.trim();
|
|
82
|
-
if (!normalizedCode)
|
|
83
|
-
throw new Error("请输入激活码");
|
|
84
|
-
const { response, body: responseBody } = await request(REDEEM_URL, {
|
|
95
|
+
/** itool.tech's route: reachable from networks where supabase.co is not. */
|
|
96
|
+
async function viaItool(options) {
|
|
97
|
+
const { response, body } = await request(ITOOL_ENTITLEMENT_URL, {
|
|
98
|
+
headers: { Authorization: `Bearer ${options.accessToken}` },
|
|
99
|
+
signal: options.signal,
|
|
100
|
+
});
|
|
101
|
+
checkStatus(response);
|
|
102
|
+
return membershipFromEntitlement(body);
|
|
103
|
+
}
|
|
104
|
+
async function viaSupabase(options) {
|
|
105
|
+
const { response, body } = await request(`${options.supabaseUrl.replace(/\/$/, "")}/rest/v1/rpc/itool_entitlement`, {
|
|
85
106
|
method: "POST",
|
|
86
|
-
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
|
87
|
-
body: JSON.stringify({
|
|
107
|
+
headers: { apikey: options.anonKey, Authorization: `Bearer ${options.accessToken}`, "Content-Type": "application/json" },
|
|
108
|
+
body: JSON.stringify({ p_product: LINKSHELL_PRODUCT }),
|
|
109
|
+
signal: options.signal,
|
|
88
110
|
});
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
111
|
+
checkStatus(response);
|
|
112
|
+
return membershipFromEntitlement(body);
|
|
113
|
+
}
|
|
114
|
+
/** The entitlement is read with the user's own token; only the server can grant it. */
|
|
115
|
+
export async function fetchProMembership(options) {
|
|
116
|
+
if (!options.accessToken.trim())
|
|
117
|
+
throw new MembershipError(LOGIN_REQUIRED, "login");
|
|
118
|
+
const supabase = options.supabaseUrl && options.anonKey ? { ...options, supabaseUrl: options.supabaseUrl, anonKey: options.anonKey } : undefined;
|
|
119
|
+
const [first, second] = options.prefer === "supabase" && supabase
|
|
120
|
+
? [() => viaSupabase(supabase), () => viaItool(options)]
|
|
121
|
+
: [() => viaItool(options), supabase ? () => viaSupabase(supabase) : undefined];
|
|
122
|
+
try {
|
|
123
|
+
return await first();
|
|
101
124
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
125
|
+
catch (error) {
|
|
126
|
+
// Only an unreachable route earns a second try: a refused token or a real answer stands.
|
|
127
|
+
if (!second || options.signal?.aborted || !(error instanceof MembershipError) || (error.kind !== "network" && error.kind !== "unavailable"))
|
|
128
|
+
throw error;
|
|
129
|
+
try {
|
|
130
|
+
return await second();
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
106
135
|
}
|
|
107
|
-
return { expiresAt: body.plan_expires_at, idempotent: body.idempotent };
|
|
108
136
|
}
|
|
109
137
|
//# sourceMappingURL=pro-membership.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pro-membership.js","sourceRoot":"","sources":["../../../../client-core/src/pro-membership.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"pro-membership.js","sourceRoot":"","sources":["../../../../client-core/src/pro-membership.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { GatewayOptions } from "./relay.js";
|
|
2
|
+
export declare const MEMBERSHIP_URL = "https://itool.tech/pro?from=linkshell";
|
|
2
3
|
export interface SubscriptionCheck {
|
|
3
4
|
/** "unknown": the lookup itself failed, which says nothing about the account. */
|
|
4
5
|
status: "active" | "inactive" | "unknown";
|
|
5
6
|
reason?: string;
|
|
6
7
|
}
|
|
7
8
|
export type CheckSubscription = (userId: string) => Promise<SubscriptionCheck>;
|
|
9
|
+
/** The RPC's jsonb answer, as the gateway reads it. */
|
|
10
|
+
export declare function subscriptionFromEntitlement(value: unknown): SubscriptionCheck;
|
|
8
11
|
export declare function supabaseSubscriptions(supabase: {
|
|
9
12
|
url: string;
|
|
10
13
|
anonKey: string;
|
|
@@ -12,7 +15,7 @@ export declare function supabaseSubscriptions(supabase: {
|
|
|
12
15
|
}): CheckSubscription;
|
|
13
16
|
/**
|
|
14
17
|
* The official gateway's rule for who may connect: a computer needs an active
|
|
15
|
-
*
|
|
18
|
+
* iTool membership. Devices only reach computers they're paired with or share an
|
|
16
19
|
* account with, so they need nothing more.
|
|
17
20
|
*/
|
|
18
21
|
export declare function requirePro(check: CheckSubscription, warn: (message: string) => void): NonNullable<GatewayOptions["admit"]>;
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
// The official gateway is part of the iTool membership: any active member (monthly or yearly)
|
|
2
|
+
// has LinkShell. The gateway asks iTool's `itool_entitlement_for(uid, 'linkshell')` with the
|
|
3
|
+
// service-role key, on its own behalf, not with the user's token.
|
|
4
|
+
export const MEMBERSHIP_URL = "https://itool.tech/pro?from=linkshell";
|
|
5
|
+
/** The RPC's jsonb answer, as the gateway reads it. */
|
|
6
|
+
export function subscriptionFromEntitlement(value) {
|
|
7
|
+
const body = value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
8
|
+
if (!body)
|
|
9
|
+
return { status: "unknown", reason: "entitlement_malformed" };
|
|
10
|
+
if (body.ok !== true)
|
|
11
|
+
return { status: "unknown", reason: `entitlement_${typeof body.code === "string" ? body.code : "refused"}` };
|
|
12
|
+
if (typeof body.active !== "boolean")
|
|
13
|
+
return { status: "unknown", reason: "entitlement_malformed" };
|
|
14
|
+
return { status: body.active ? "active" : "inactive" };
|
|
15
|
+
}
|
|
1
16
|
export function supabaseSubscriptions(supabase) {
|
|
2
17
|
return async (userId) => {
|
|
3
18
|
if (!supabase.url || !supabase.anonKey)
|
|
@@ -5,27 +20,24 @@ export function supabaseSubscriptions(supabase) {
|
|
|
5
20
|
if (!supabase.serviceRoleKey)
|
|
6
21
|
return { status: "unknown", reason: "missing_service_role_key" };
|
|
7
22
|
try {
|
|
8
|
-
const response = await fetch(`${supabase.url}/rest/v1/
|
|
9
|
-
|
|
23
|
+
const response = await fetch(`${supabase.url.replace(/\/$/, "")}/rest/v1/rpc/itool_entitlement_for`, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { Authorization: `Bearer ${supabase.serviceRoleKey}`, apikey: supabase.anonKey, "Content-Type": "application/json" },
|
|
26
|
+
body: JSON.stringify({ p_user: userId, p_product: "linkshell" }),
|
|
10
27
|
signal: AbortSignal.timeout(5_000),
|
|
11
28
|
});
|
|
12
29
|
if (!response.ok)
|
|
13
|
-
return { status: "unknown", reason: `
|
|
14
|
-
|
|
15
|
-
const profile = profiles[0];
|
|
16
|
-
if (!profile)
|
|
17
|
-
return { status: "unknown", reason: "profile_not_found" };
|
|
18
|
-
const active = profile.plan === "pro" && !!profile.plan_expires_at && new Date(profile.plan_expires_at) > new Date();
|
|
19
|
-
return { status: active ? "active" : "inactive" };
|
|
30
|
+
return { status: "unknown", reason: `entitlement_lookup_http_${response.status}` };
|
|
31
|
+
return subscriptionFromEntitlement(await response.json());
|
|
20
32
|
}
|
|
21
33
|
catch {
|
|
22
|
-
return { status: "unknown", reason: "
|
|
34
|
+
return { status: "unknown", reason: "entitlement_lookup_failed" };
|
|
23
35
|
}
|
|
24
36
|
};
|
|
25
37
|
}
|
|
26
38
|
/**
|
|
27
39
|
* The official gateway's rule for who may connect: a computer needs an active
|
|
28
|
-
*
|
|
40
|
+
* iTool membership. Devices only reach computers they're paired with or share an
|
|
29
41
|
* account with, so they need nothing more.
|
|
30
42
|
*/
|
|
31
43
|
export function requirePro(check, warn) {
|
|
@@ -33,11 +45,11 @@ export function requirePro(check, warn) {
|
|
|
33
45
|
if (role !== "machine")
|
|
34
46
|
return undefined;
|
|
35
47
|
if (!userId)
|
|
36
|
-
return "官方网关需要登录
|
|
48
|
+
return "官方网关需要登录 iTool 会员账号:在电脑上运行 linkshell login";
|
|
37
49
|
const subscription = await check(userId);
|
|
38
50
|
// A lookup failure isn't a lapsed subscription; don't lock people out over it.
|
|
39
51
|
if (subscription.status === "inactive")
|
|
40
|
-
return
|
|
52
|
+
return `官方网关需要 iTool 会员:${MEMBERSHIP_URL}`;
|
|
41
53
|
if (subscription.status === "unknown")
|
|
42
54
|
warn(`subscription check unavailable (${subscription.reason}); admitting ${userId}`);
|
|
43
55
|
return undefined;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"subscription.js","sourceRoot":"","sources":["../../../../gateway/src/subscription.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"subscription.js","sourceRoot":"","sources":["../../../../gateway/src/subscription.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linkshell-cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.94",
|
|
4
4
|
"description": "Follow, steer and approve the coding agents on your computer (Claude Code, Codex, Gemini, Copilot and more) from your phone",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/cli/src/index.js",
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
"node-pty": "^1.1.0",
|
|
35
35
|
"qrcode-terminal": "^0.12.0",
|
|
36
36
|
"ws": "^8.21.0",
|
|
37
|
-
"@linkshell/
|
|
37
|
+
"@linkshell/host": "0.4.88",
|
|
38
38
|
"@linkshell/wire": "0.2.28",
|
|
39
|
-
"@linkshell/
|
|
39
|
+
"@linkshell/gateway": "0.6.58"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@types/ws": "^8.5.14",
|
package/src/commands/desktop.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { prepareDesktopUpdate, recoverDesktopUpdate, stopDesktopUpdateHost } fro
|
|
|
12
12
|
import { sep, join } from "node:path";
|
|
13
13
|
import { ensureHostRunning, silenceSqliteWarning, withRunningHost } from "./host.js";
|
|
14
14
|
import { linkshellDir, readHostLaunch, readHostOwner, readPid, stopDaemonAndWait } from "../utils/daemon.js";
|
|
15
|
-
import { cachedMembership,
|
|
15
|
+
import { cachedMembership, refreshMembership } from "../pro-membership.js";
|
|
16
16
|
import { accessPasswordEnabled, applyAccessPassword } from "./password.js";
|
|
17
17
|
|
|
18
18
|
export function installationSource(path: string): "homebrew" | "npm" {
|
|
@@ -104,52 +104,14 @@ async function credentialsInput(input: AsyncIterable<string | Buffer>): Promise<
|
|
|
104
104
|
throw new Error("请输入邮箱和密码。");
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
async function redemptionInput(input: AsyncIterable<string | Buffer>): Promise<{ code: string; userId: string }> {
|
|
108
|
-
const chunks: Buffer[] = [];
|
|
109
|
-
let length = 0;
|
|
110
|
-
for await (const chunk of input) {
|
|
111
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
112
|
-
length += buffer.length;
|
|
113
|
-
if (length > 4096) throw new Error("激活码内容过长,请检查后重试");
|
|
114
|
-
chunks.push(buffer);
|
|
115
|
-
}
|
|
116
|
-
try {
|
|
117
|
-
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
118
|
-
if (typeof value?.code === "string" && typeof value?.userId === "string" && value.userId.trim()) return { code: value.code, userId: value.userId };
|
|
119
|
-
} catch { /* Never echo an input that contains the activation code. */ }
|
|
120
|
-
throw new Error("激活码请求无效,请刷新账号后重试");
|
|
121
|
-
}
|
|
122
|
-
|
|
123
107
|
export async function runDesktop(version: string, action: string, input: AsyncIterable<string | Buffer> = process.stdin, options: { provider?: string } = {}): Promise<void> {
|
|
124
108
|
silenceSqliteWarning();
|
|
125
109
|
try {
|
|
126
|
-
if (action === "redeem-pro") {
|
|
127
|
-
const { code, userId } = await redemptionInput(input);
|
|
128
|
-
const result = await redeemDesktopPro(code, userId);
|
|
129
|
-
if (loadAuth()?.userId === userId) {
|
|
130
|
-
try {
|
|
131
|
-
const gateway = await refreshGateway(15_000);
|
|
132
|
-
if (gateway && gateway.status !== "online" && gateway.status !== "off") result.accountWarning ??= "兑换已成功,网关尚未连接,请稍后重新连接";
|
|
133
|
-
} catch {
|
|
134
|
-
result.accountWarning ??= "兑换已成功,本机服务暂未刷新,请稍后重新连接网关";
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
// A host query failing cannot turn a confirmed redemption into a failed redemption.
|
|
138
|
-
const snapshot = await desktopSnapshot(version).catch(() => {
|
|
139
|
-
result.accountWarning ??= "兑换已成功,本机服务状态暂时无法读取,请稍后刷新";
|
|
140
|
-
return { schema: 1, cliVersion: version, status: "unknown", account: desktopAccount() };
|
|
141
|
-
});
|
|
142
|
-
snapshot.account = desktopAccount();
|
|
143
|
-
if (snapshot.account.state === "signed-in" && "userId" in snapshot.account && snapshot.account.userId === userId) Object.assign(snapshot.account, { plan: "pro", planExpiresAt: result.redemption.planExpiresAt });
|
|
144
|
-
else result.accountWarning = "兑换已成功,登录账号已变更,请切回兑换时的账号查看会员状态";
|
|
145
|
-
process.stdout.write(`${JSON.stringify({ ...snapshot, ...result })}\n`);
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
110
|
if (action === "account-refresh") await followAccountGateway((await refreshMembership()).plan);
|
|
149
111
|
else if (action === "pair" || action === "pair-qr") {
|
|
150
112
|
const result = await withRunningHost(async client => {
|
|
151
113
|
const gateway = await client.call("gateway.status", {});
|
|
152
|
-
if (gateway.status !== "online") throw new Error("请先连接网关。官方网关需要登录
|
|
114
|
+
if (gateway.status !== "online") throw new Error("请先连接网关。官方网关需要登录 iTool 会员账号;自建网关可以免登录配对。");
|
|
153
115
|
return client.call("pairing.start", {}, 20_000);
|
|
154
116
|
});
|
|
155
117
|
if (!result) throw new Error("请先启动本机服务");
|
package/src/commands/host.ts
CHANGED
|
@@ -23,7 +23,7 @@ interface HostConfig {
|
|
|
23
23
|
setup?: "done";
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/** LinkShell's own gateway:
|
|
26
|
+
/** LinkShell's own gateway: an iTool member's computers are reachable there. */
|
|
27
27
|
export const OFFICIAL_GATEWAY = "wss://gateway.itool.tech";
|
|
28
28
|
|
|
29
29
|
/** Whether two gateway addresses name the same one, however they were typed (scheme, trailing slash). */
|
|
@@ -208,7 +208,7 @@ export function describeGateway(gateway: GatewayStatus): string {
|
|
|
208
208
|
|
|
209
209
|
/** After `linkshell login`: this computer joins the gateway its account is for. */
|
|
210
210
|
/**
|
|
211
|
-
* A signed-in
|
|
211
|
+
* A signed-in member's computer belongs on the official gateway: nobody should have to find
|
|
212
212
|
* a button for it before their phone can see this computer. A gateway of one's own (a URL) is kept;
|
|
213
213
|
* "off" is not. A stopped host is left stopped. True when the host was asked to reconnect.
|
|
214
214
|
*/
|
|
@@ -244,7 +244,7 @@ export async function joinGatewayAfterLogin(plan: "pro" | "free"): Promise<void>
|
|
|
244
244
|
if (!chosen && plan !== "pro") {
|
|
245
245
|
// A running host now gets the gateway's own answer, instead of an old login's.
|
|
246
246
|
await refreshGateway(0);
|
|
247
|
-
out("The official gateway needs
|
|
247
|
+
out("The official gateway needs an iTool membership (monthly or yearly): https://itool.tech/pro?from=linkshell");
|
|
248
248
|
out("Or use your own gateway: linkshell host --gateway <url>, then linkshell pair\n");
|
|
249
249
|
return;
|
|
250
250
|
}
|
package/src/commands/login.ts
CHANGED
|
@@ -161,7 +161,7 @@ export async function runLogin(options: { signal?: AbortSignal } = {}): Promise<
|
|
|
161
161
|
res.writeHead(200);
|
|
162
162
|
res.end("ok");
|
|
163
163
|
|
|
164
|
-
const planLabel = result.plan === "pro" ?
|
|
164
|
+
const planLabel = result.plan === "pro" ? `\x1b[32m${result.tier === "yearly" ? "iTool yearly member" : "iTool monthly member"}\x1b[0m` : "Free";
|
|
165
165
|
process.stderr.write(
|
|
166
166
|
` \x1b[32m✓\x1b[0m Logged in as ${result.email || result.userId} (${planLabel})\n\n`,
|
|
167
167
|
);
|
package/src/commands/setup.ts
CHANGED
|
@@ -66,7 +66,7 @@ async function setUpPhone(out: (line?: string) => void, interactive: boolean): P
|
|
|
66
66
|
out(" Not connected to a phone yet: linkshell login (LinkShell account), or your own gateway and linkshell pair.");
|
|
67
67
|
return false;
|
|
68
68
|
}
|
|
69
|
-
out(" Your phone reaches this computer through your LinkShell account (
|
|
69
|
+
out(" Your phone reaches this computer through your LinkShell account (iTool membership), from anywhere.");
|
|
70
70
|
const answer = await ask(" Sign in now? It opens your browser. [Y/n] ");
|
|
71
71
|
if (answer === "n" || answer === "no") {
|
|
72
72
|
out(dim(" Later: linkshell login — or with your own gateway: linkshell host --gateway <url>, then linkshell pair"));
|
package/src/commands/sign-in.ts
CHANGED
|
@@ -2,8 +2,7 @@ import * as http from "node:http";
|
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { saveAuth, SUPABASE_ANON_KEY, SUPABASE_URL, type AuthTokens } from "../auth.js";
|
|
5
|
-
import { cacheMembership, type DesktopMembership } from "../pro-membership.js";
|
|
6
|
-
import { fetchProMembership } from "../../../client-core/src/pro-membership.js";
|
|
5
|
+
import { cacheMembership, lookupMembership, type DesktopMembership } from "../pro-membership.js";
|
|
7
6
|
import { isDefinitiveAccountError, oauthErrorCode, tokenErrorCode } from "../../../client-core/src/account-errors.js";
|
|
8
7
|
import { LoginError } from "./login-error.js";
|
|
9
8
|
|
|
@@ -17,6 +16,8 @@ import { LoginError } from "./login-error.js";
|
|
|
17
16
|
export interface LoginResult {
|
|
18
17
|
success: boolean;
|
|
19
18
|
plan: "pro" | "free";
|
|
19
|
+
/** The iTool membership tier when `plan` is "pro". */
|
|
20
|
+
tier?: "monthly" | "yearly";
|
|
20
21
|
email: string;
|
|
21
22
|
userId: string;
|
|
22
23
|
accessToken: string;
|
|
@@ -81,10 +82,9 @@ async function fetchUser(accessToken: string, signal?: AbortSignal): Promise<{ i
|
|
|
81
82
|
export async function completeLogin(grant: SessionGrant, options: { signal?: AbortSignal } = {}): Promise<LoginResult> {
|
|
82
83
|
if (options.signal?.aborted) throw new LoginError("cancelled");
|
|
83
84
|
const user = grant.user ?? await fetchUser(grant.accessToken, options.signal);
|
|
84
|
-
let membership: DesktopMembership = { plan: "unknown", planExpiresAt: null };
|
|
85
|
+
let membership: DesktopMembership = { plan: "unknown", planExpiresAt: null, tier: null };
|
|
85
86
|
try {
|
|
86
|
-
|
|
87
|
-
membership = { plan: profile.plan, planExpiresAt: profile.expiresAt ?? null };
|
|
87
|
+
membership = await lookupMembership(grant.accessToken, options.signal);
|
|
88
88
|
} catch { /* Unknown until the next refresh. */ }
|
|
89
89
|
// The owner gave up while this was in flight: an existing login must stay as it was.
|
|
90
90
|
if (options.signal?.aborted) throw new LoginError("cancelled");
|
|
@@ -98,7 +98,7 @@ export async function completeLogin(grant: SessionGrant, options: { signal?: Abo
|
|
|
98
98
|
};
|
|
99
99
|
saveAuth(tokens);
|
|
100
100
|
try { cacheMembership(user.id, membership); } catch { /* A cache write cannot invalidate a completed login. */ }
|
|
101
|
-
return { success: true, plan: membership.plan === "pro" ? "pro" : "free", email, userId: user.id, accessToken: grant.accessToken };
|
|
101
|
+
return { success: true, plan: membership.plan === "pro" ? "pro" : "free", ...(membership.plan === "pro" && membership.tier ? { tier: membership.tier } : {}), email, userId: user.id, accessToken: grant.accessToken };
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
function parseGrant(body: unknown): SessionGrant | undefined {
|