@tiens.nguyen/gu-cli 1.0.776 → 1.0.778

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.
Files changed (3) hide show
  1. package/cli-token.mjs +113 -19
  2. package/gu-repl.mjs +80 -13
  3. package/package.json +1 -1
package/cli-token.mjs CHANGED
@@ -90,7 +90,10 @@ export function verifyCliToken(token, publicKeyB64) {
90
90
  // Missing means OFF — tokens issued before the switch existed (#238) are in CLIs right now.
91
91
  !(p.enforce === undefined || typeof p.enforce === "boolean") ||
92
92
  // Missing is every token before #267; present, it must be an address.
93
- !(p.email === undefined || isEmail(p.email))
93
+ !(p.email === undefined || isEmail(p.email)) ||
94
+ // #315: this computer's free time — missing = unknown, null = no limit (the owner's Mark OK).
95
+ !(p.freeUntil === undefined || p.freeUntil === null || isIso(p.freeUntil)) ||
96
+ !(p.freeAccounts === undefined || (Number.isInteger(p.freeAccounts) && p.freeAccounts >= 1))
94
97
  ) {
95
98
  return { ok: false, why: "bad-payload" };
96
99
  }
@@ -172,6 +175,13 @@ export async function claimTrial({ hostedBase, device, fetchImpl = fetch, timeou
172
175
  }
173
176
  }
174
177
 
178
+ /** Where a person who thinks a lock is wrong can write (#315 P7) — the address the website shows. */
179
+ export const SUPPORT_EMAIL = "support@getmarsic.cc";
180
+
181
+ /** The Cursor-style line (#315 W2): matter-of-fact, never "abuse", never the other account. */
182
+ export const FREE_USED_UP_MANY =
183
+ "Too many free accounts used on this computer. Its free time is used up — enter a Pro token to continue.";
184
+
175
185
  /** Whole days left until an ISO date (rounded up; 0 when past). */
176
186
  export function daysLeft(iso, now = new Date()) {
177
187
  const ms = Date.parse(iso ?? "") - now.getTime();
@@ -179,9 +189,10 @@ export function daysLeft(iso, now = new Date()) {
179
189
  }
180
190
 
181
191
  /** The one line a first install sees instead of a token question (#305). */
182
- export function trialStartedLine(endsAt, site = DEFAULT_SITE) {
192
+ export function trialStartedLine(endsAt, site = DEFAULT_SITE, now = new Date()) {
183
193
  const accountUrl = `${String(site).replace(/^https?:\/\//, "")}/account`;
184
- return `Free trial on this computer: 10 days, until ${shortDate(endsAt)}. ` +
194
+ const n = daysLeft(endsAt, now);
195
+ return `Free trial on this computer: ${n} day${n === 1 ? "" : "s"}, until ${shortDate(endsAt)}. ` +
185
196
  `Then get a free token for 1 computer at ${accountUrl} — gu token <paste> links it.`;
186
197
  }
187
198
 
@@ -198,7 +209,7 @@ export function shortToken(token) {
198
209
  * THE TOKEN QUESTION, WITH THE TRIAL AS ITS DEFAULT (#305/#76, placed by #314).
199
210
  *
200
211
  * Input token: gu1.eyJ2IjoxLCJz…Xk9aQw ← typed in: Enter takes it, or delete it
201
- * This is a free trial token: 10 days on this computer, until 10 Oct 2026. ← yellow, under
212
+ * This is a free trial token: 30 days left on this computer, until 30 Oct 2026. ← yellow, under
202
213
  * For a free token for 1 computer, log in at gu.example/account and create one — or buy Pro …
203
214
  *
204
215
  * The trial is fetched first (it is the same one every time for this computer). Nothing is saved
@@ -226,6 +237,12 @@ export async function promptForToken({
226
237
  publicKeyB64 = publicKey(),
227
238
  required = false,
228
239
  maxRounds = 1000, // a bound for tests; a person answers long before this
240
+ // #315: WHY the question is asked when a token exists but is no longer entitled (the computer's
241
+ // free time is used up, the trial ended…) — replaces the "needs a token" title, adds the way on.
242
+ why = "",
243
+ // #315: a saved token must also be ENTITLED on this computer. `accept(token)` → { ok } | { ok:false, reason }
244
+ // — the REPL renews it from this computer (to learn its free time) and runs the licence rule.
245
+ accept = null,
229
246
  }) {
230
247
  const accountUrl = `${String(site).replace(/^https?:\/\//, "")}/account`;
231
248
  const dev = device === undefined ? await trialDevice().catch(() => null) : device;
@@ -236,16 +253,32 @@ export async function promptForToken({
236
253
  trialOk = Boolean(trial.ok && verifyCliToken(trial.token, publicKeyB64).ok);
237
254
  shown = trialOk ? shortToken(trial.token) : "";
238
255
  hint = trialOk
239
- ? [`This is a free trial token: 10 days on this computer, until ${shortDate(trial.endsAt)}.`, getOne, "Enter uses it — or delete it (one Backspace) and paste your own token."]
256
+ ? [`This is a free trial token: ${daysLeft(trial.endsAt)} days left on this computer, until ${shortDate(trial.endsAt)}.`, getOne, "Enter uses it — or delete it (one Backspace) and paste your own token."]
240
257
  : [
241
258
  ...(trial.why === "used" && trial.endedAt ? [`The free trial on this computer ended on ${shortDate(trial.endedAt)}.`] : []),
242
259
  ...(trial.why === "unreachable" ? ["The gu service can't be reached right now — Enter on an empty line tries again."] : []),
243
260
  getOne,
244
261
  required ? "Paste it here, then Enter." : "Paste it here — or Enter to skip for now.",
245
262
  ];
263
+ if (why) {
264
+ const pricingUrl = `${String(site).replace(/^https?:\/\//, "")}/pricing`;
265
+ hint = [
266
+ // The reason (the title) already says what happened; a "trial ended on <date>" line beside it is
267
+ // noise — and wrong for a computer that went straight to a free account and never had one.
268
+ ...hint.filter((l) => !/^For a free token|^Paste it here|^The free trial on this computer ended/.test(l)),
269
+ `Get Pro at ${pricingUrl} · Think this is wrong? ${SUPPORT_EMAIL}`,
270
+ ...(trialOk ? [] : ["Paste a Pro token here, then Enter."]),
271
+ ];
272
+ }
246
273
  };
247
274
  await offer();
248
- log("This computer needs a gu token before the agent can start — one time only.", "title");
275
+ log(why || "This computer needs a gu token before the agent can start — one time only.", "title");
276
+ const entitled = async (token) => {
277
+ if (!accept) return true;
278
+ const r = await accept(token).catch(() => ({ ok: true })); // unknown = soft, like every other check here
279
+ if (!r.ok) log(`That token can't be used on this computer: ${r.reason}`, "warn");
280
+ return r.ok;
281
+ };
249
282
  const ask1 = () => ask("Input token: ", { prefill: shown, hint, atomic: true, noCancel: required });
250
283
  for (let round = 0; required ? round < maxRounds : round < 2; round++) {
251
284
  const typed = String((await ask1()) ?? "").trim();
@@ -256,11 +289,13 @@ export async function promptForToken({
256
289
  }
257
290
  if (trialOk && (typed === shown || typed === trial.token)) {
258
291
  await writeState(dir, { token: trial.token, lastAttemptAt: new Date().toISOString() });
259
- log(`✓ free trial token saved — 10 days on this computer, until ${shortDate(trial.endsAt)}.`, "ok");
292
+ if (!(await entitled(trial.token))) continue;
293
+ log(`✓ free trial token saved — ${daysLeft(trial.endsAt)} days left on this computer, until ${shortDate(trial.endsAt)}.`, "ok");
260
294
  return { ok: true, kind: "trial" };
261
295
  }
262
296
  const saved = await saveTokenFromPaste(dir, typed, publicKeyB64);
263
297
  if (saved.ok) {
298
+ if (!(await entitled(typed))) continue;
264
299
  log("✓ token saved — it renews itself from now on.", "ok");
265
300
  return { ok: true, kind: "pasted" };
266
301
  }
@@ -435,6 +470,17 @@ export function barNotice({ token, publicKeyB64, mode, now = new Date(), attempt
435
470
  const left = daysLeft(p.paidUntil, now);
436
471
  return { kind: "trial", urgent: false, text: `trial · ${left} day${left === 1 ? "" : "s"} left · ${way}` };
437
472
  }
473
+ // FREE TIME IS THE COMPUTER'S (#315): the days left on THIS computer, and — when more than one free
474
+ // account uses it — that they share them (W1). Yellow once it matters: used up, shared, or two weeks left.
475
+ if (p.plan === "free" && typeof p.freeUntil === "string") {
476
+ const left = daysLeft(p.freeUntil, now);
477
+ const shared = Number.isInteger(p.freeAccounts) && p.freeAccounts > 1;
478
+ if (left === 0) return { kind: "free-used", urgent: true, text: `free time on this computer is used up · Pro at ${pricingUrl}` };
479
+ const days = `${left} day${left === 1 ? "" : "s"}`;
480
+ return shared
481
+ ? { kind: "free-shared", urgent: true, text: `More than one free account on this computer · they share ${days} of free time · Pro at ${pricingUrl}` }
482
+ : { kind: "free", urgent: left <= 14, text: `free · ${days} left on this computer · Pro at ${pricingUrl}` };
483
+ }
438
484
  // SHORT AND CALM (#292: "make it simpler … seems so serious"): the fact, then the one thing to
439
485
  // do. `urgent` is what the bar colours by — only these two need the user to act.
440
486
  if (ended) return { kind: "plan-ended", urgent: true, text: `Pro ended ${shortDate(p.paidUntil)} · renew at ${pricingUrl}` };
@@ -550,7 +596,17 @@ export function tokenSummary(payload, now = new Date()) {
550
596
  * TURNS — `gu login`, `gu token`, /model and every other command keep working, because the person has
551
597
  * to be able to fix it from where they are.
552
598
  */
553
- export function enforcementDecision({ token, publicKeyB64, now = new Date(), site = DEFAULT_SITE }) {
599
+ /** #315 O3: how long a token may go without being renewed online before gu asks to connect. */
600
+ export const MAX_OFFLINE_DAYS = { free: 7, trial: 7, pro: 30 };
601
+ /** #315 O2: how far the computer's clock may run behind the server's before a token reads as "from the future". */
602
+ export const CLOCK_SKEW_MS = 10 * 60_000;
603
+
604
+ /**
605
+ * `deviceId` (#315 O1): THIS computer's hash, when the caller knows it. A free token checked on
606
+ * another computer — or never on any (a web-issued one) — says nothing about THIS computer's free
607
+ * time, so it needs one online check here first.
608
+ */
609
+ export function enforcementDecision({ token, publicKeyB64, now = new Date(), site = DEFAULT_SITE, deviceId }) {
554
610
  if (!publicKeyB64) return { enforced: false, allowed: true, why: "" };
555
611
  const check = token ? verifyCliToken(token, publicKeyB64) : { ok: false, why: "missing" };
556
612
  // An unreadable or missing token cannot say whether enforcement is on, and this must not be a way
@@ -564,17 +620,55 @@ export function enforcementDecision({ token, publicKeyB64, now = new Date(), sit
564
620
  const pricingUrl = `${host}/pricing`;
565
621
  const accountUrl = `${host}/account`;
566
622
  const ended = p.paidUntil && Date.parse(p.paidUntil) <= now.getTime();
567
- // A running free trial (#305) is entitled like Pro, for its ten days.
568
- if ((p.plan !== "pro" && p.plan !== "trial") || ended) {
569
- return {
570
- enforced: true,
571
- allowed: false,
572
- why: ended && p.plan === "trial"
573
- ? `The free trial on this computer ended on ${shortDate(p.paidUntil)}. Go Pro at ${pricingUrl} to keep using the agent.`
574
- : ended
575
- ? `Your gu Pro plan ended on ${shortDate(p.paidUntil)}. Renew at ${pricingUrl} to keep using the agent.`
576
- : `gu needs a Pro plan on this account. See ${pricingUrl} — the rest of gu still works.`,
577
- };
623
+ const blocked = (why) => ({ enforced: true, allowed: false, why });
624
+ // OFFLINE CANNOT UNLOCK ANYTHING (#315, the owner: "if the user cannot reach the internet or our
625
+ // API, it should still be blocked"). Three ways a token could otherwise outlive what it says:
626
+ // O2 — the clock set back: a token "issued in the future" is not trusted;
627
+ const issued = Date.parse(p.issuedAt);
628
+ if (issued - now.getTime() > CLOCK_SKEW_MS) {
629
+ return blocked(
630
+ `This computer's clock is behind (your gu token was issued ${shortDate(p.issuedAt)}). ` +
631
+ "Set the correct date and time, then start gu again."
632
+ );
633
+ }
634
+ // O3 — staying offline: a token must have been renewed online recently (Pro gets longer);
635
+ const paid = p.plan === "pro" && !ended;
636
+ const maxDays = paid ? MAX_OFFLINE_DAYS.pro : p.plan === "trial" ? MAX_OFFLINE_DAYS.trial : MAX_OFFLINE_DAYS.free;
637
+ if (now.getTime() - issued > maxDays * 86_400_000) {
638
+ return blocked(
639
+ `gu needs to check your token online — it was last checked ${shortDate(p.issuedAt)}. ` +
640
+ "Connect to the internet and start gu again."
641
+ );
642
+ }
643
+ // O1 — a free token not yet checked on THIS computer (see the free branch below).
644
+ // THE TRIAL (#305): entitled for its days; after them a free account gives THIS computer 3 months.
645
+ if (p.plan === "trial") {
646
+ if (ended) {
647
+ return blocked(
648
+ `The free trial on this computer ended on ${shortDate(p.paidUntil)}. A free gu account gives it 3 months ` +
649
+ `more — get your token at ${accountUrl} — or go Pro at ${pricingUrl}.`
650
+ );
651
+ }
652
+ } else if (p.plan === "pro" && !ended) {
653
+ // Pro: the computer-count check below.
654
+ } else if (p.plan === "pro" && ended && p.freeUntil === undefined) {
655
+ // An old Pro token, issued while paid and not renewed since: it cannot know this computer's free time.
656
+ return blocked(`Your gu Pro plan ended on ${shortDate(p.paidUntil)}. Renew at ${pricingUrl} to keep using the agent.`);
657
+ } else {
658
+ // FREE (#315): the time belongs to the COMPUTER — 3 months, shared by every free account on it.
659
+ // O1: the token must have been renewed FROM THIS COMPUTER — a web-issued token (no device) or one
660
+ // copied from another computer says nothing about this one's free time. null = the owner marked
661
+ // this computer OK. undefined on a token renewed here = a server with no free-time store: allowed.
662
+ const notHere = !p.device || (deviceId && p.device !== deviceId);
663
+ if (notHere) return blocked("Connect to the internet once to activate this token on this computer.");
664
+ const fu = p.freeUntil;
665
+ if (fu !== undefined && fu !== null && Date.parse(fu) <= now.getTime()) {
666
+ return blocked(
667
+ Number.isInteger(p.freeAccounts) && p.freeAccounts > 1
668
+ ? FREE_USED_UP_MANY
669
+ : `The free time on this computer ended on ${shortDate(fu)} — enter a Pro token to continue.`
670
+ );
671
+ }
578
672
  }
579
673
  if (Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
580
674
  return {
package/gu-repl.mjs CHANGED
@@ -2280,9 +2280,10 @@ async function refreshCliToken() {
2280
2280
  }
2281
2281
  if (!licenceDecided) {
2282
2282
  licenceDecided = true;
2283
- const d = ct.enforcementDecision({ token: state.token, publicKeyB64: pk, site });
2283
+ const d = ct.enforcementDecision({ token: state.token, publicKeyB64: pk, site, deviceId: await thisDeviceId(ct) });
2284
2284
  licenceBlock = d.enforced && !d.allowed ? d.why : null;
2285
- if (licenceBlock) {
2285
+ // Private mode at a terminal: the start-up token row says it, and offers the way on (#315).
2286
+ if (licenceBlock && !(isClientExpert() && process.stdin.isTTY)) {
2286
2287
  console.log("");
2287
2288
  console.log(GUTTER + orange("gu cannot run turns on this account.") );
2288
2289
  console.log(GUTTER + dim(" " + licenceBlock));
@@ -10008,32 +10009,98 @@ async function ensureTokenForTurn() {
10008
10009
  * (Esc does nothing), one Backspace clears the default for a paste, and a valid token turns the
10009
10010
  * row back into ">> ". → true once a token is saved.
10010
10011
  */
10011
- async function askForCliToken(ct, dir) {
10012
+ async function askForCliToken(ct, dir, { why = "" } = {}) {
10012
10013
  console.log("");
10013
- const { DEFAULT_API_BASE } = await import("./device-login.mjs");
10014
+ const hostedBase = await tokenHostedBase();
10015
+ const pk = ct.publicKey();
10016
+ const site = ct.siteUrl();
10017
+ const deviceId = await thisDeviceId(ct);
10014
10018
  const r = await ct.promptForToken({
10015
10019
  ask: (label, opts) => ask(label, opts),
10016
10020
  dir,
10017
10021
  required: true,
10018
- hostedBase: String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE,
10019
- log: (l, kind) =>
10020
- console.log(GUTTER + (kind === "title" ? yellow(l) : kind === "ok" ? accent(l) : kind === "warn" ? yellow(l) : dim(l))),
10022
+ hostedBase,
10023
+ why,
10024
+ // #315: a token is only good if THIS computer may use it — renew it from here (the server then
10025
+ // says how much free time the computer has), save what came back, and run the licence rule.
10026
+ accept: async (token) => {
10027
+ const fresh = await renewFromThisComputer(ct, token, hostedBase);
10028
+ const use = fresh ?? token;
10029
+ if (fresh) await ct.writeState(dir, { token: fresh, lastAttemptAt: new Date().toISOString() });
10030
+ const d = ct.enforcementDecision({ token: use, publicKeyB64: pk, site, deviceId });
10031
+ return d.allowed ? { ok: true } : { ok: false, reason: d.why };
10032
+ },
10033
+ // Word-wrapped to the window: a long reason ("Too many free accounts used on this computer…")
10034
+ // otherwise breaks mid-word at 80 columns.
10035
+ log: (l, kind) => {
10036
+ const paint = kind === "title" || kind === "warn" ? yellow : kind === "ok" ? accent : dim;
10037
+ for (const row of questionHintRows([l], { width: process.stdout.columns || 80, gutter: GUTTER, paint })) console.log(row);
10038
+ },
10021
10039
  });
10022
10040
  console.log("");
10023
- void refreshCliToken(); // the bar's trial line, now that there is a token to describe
10041
+ if (r.ok) {
10042
+ licenceBlock = null; // the row only lets an entitled token through
10043
+ licenceDecided = true;
10044
+ }
10045
+ void refreshCliToken(); // the bar's line, now that there is a token to describe
10024
10046
  return r.ok;
10025
10047
  }
10026
10048
 
10027
- /** At the first ">>": private mode with no usable token asks for one first (#314). */
10049
+ /** This computer's hash (#315 O1) — the same id every renewal sends — or undefined when unknown. */
10050
+ const thisDeviceId = async (ct) => (await ct.thisDevice({ instanceId: myInstanceId || "" }).catch(() => null))?.id;
10051
+
10052
+ const tokenHostedBase = async () => {
10053
+ const { DEFAULT_API_BASE } = await import("./device-login.mjs");
10054
+ return String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE;
10055
+ };
10056
+
10057
+ /** A renewed token for THIS computer (so the server counts it and says its free time), or null. */
10058
+ async function renewFromThisComputer(ct, token, hostedBase) {
10059
+ const pk = ct.publicKey();
10060
+ const device = await ct.thisDevice({ instanceId: myInstanceId || "" }).catch(() => null);
10061
+ const r = await ct.renewToken({ mode: "expert", hostedBase, token, device }).catch(() => ({ ok: false }));
10062
+ return r.ok && ct.verifyCliToken(r.token, pk).ok ? r.token : null;
10063
+ }
10064
+
10065
+ /**
10066
+ * At the first ">>" in private mode (#78, #315): no usable token → ask for one; a token this
10067
+ * computer may no longer use (its free time is used up, the trial ended — enforcement is on by
10068
+ * default) → the same row, saying why, and only an ENTITLED token leads to ">>".
10069
+ */
10028
10070
  async function askTokenAtStart() {
10029
10071
  if (!isClientExpert() || !process.stdin.isTTY) return;
10030
10072
  const ct = await import("./cli-token.mjs");
10031
- if (!ct.publicKey()) return;
10073
+ const pk = ct.publicKey();
10074
+ if (!pk) return;
10075
+ // The background refresh started at launch renews and WRITES the token: let it finish, or it
10076
+ // could overwrite a token pasted here with the old one — and its answer is the freshest anyway.
10077
+ await startupTokenRefresh?.catch(() => undefined);
10032
10078
  const dir = join(homedir(), ".gonext");
10033
10079
  const state = await ct.readState(dir);
10034
- if (!ct.turnNeedsToken({ privateMode: true, token: state.token })) return;
10035
- await askForCliToken(ct, dir);
10080
+ if (ct.turnNeedsToken({ privateMode: true, token: state.token })) {
10081
+ await askForCliToken(ct, dir);
10082
+ return;
10083
+ }
10084
+ const site = ct.siteUrl();
10085
+ const deviceId = await thisDeviceId(ct);
10086
+ let d = ct.enforcementDecision({ token: state.token, publicKeyB64: pk, site, deviceId });
10087
+ if (d.allowed) return;
10088
+ // Before locking, ask the server once more from THIS computer: a plan paid a minute ago, or the
10089
+ // owner's "Mark OK", arrives in a renewed token — never lock on a stale one.
10090
+ const fresh = await renewFromThisComputer(ct, state.token, await tokenHostedBase());
10091
+ if (fresh) {
10092
+ await ct.writeState(dir, { token: fresh, lastAttemptAt: new Date().toISOString() });
10093
+ d = ct.enforcementDecision({ token: fresh, publicKeyB64: pk, site, deviceId });
10094
+ if (d.allowed) {
10095
+ licenceBlock = null;
10096
+ licenceDecided = true;
10097
+ return;
10098
+ }
10099
+ }
10100
+ await askForCliToken(ct, dir, { why: d.why });
10036
10101
  }
10102
+ /** The token refresh started at launch — askTokenAtStart waits for it (see there). */
10103
+ let startupTokenRefresh = null;
10037
10104
 
10038
10105
  async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {}) {
10039
10106
  // #238: when the licence is ENFORCED and this account is not entitled, no turn is sent. Decided at
@@ -12122,7 +12189,7 @@ async function main() {
12122
12189
  void refreshAvailableUpdates();
12123
12190
  setInterval(() => void refreshAvailableUpdates(), UPDATE_CHECK_MS).unref();
12124
12191
  // The gu token (#232): same arrangement — background, never delays the prompt, unref'd.
12125
- void refreshCliToken();
12192
+ startupTokenRefresh = refreshCliToken();
12126
12193
  setInterval(() => void refreshCliToken(), TOKEN_TICK_MS).unref();
12127
12194
  // …and the other-agents count (task #195). Slower than the turn's own poll and skipped while
12128
12195
  // a turn runs; unref'd so a pending timer is never the reason gu refuses to exit.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.776",
3
+ "version": "1.0.778",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",