@stemtrooper/learningcode 0.4.1 → 0.4.3

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 CHANGED
@@ -63,6 +63,34 @@ npm install -g @stemtrooper/learningcode
63
63
  learningcode --version
64
64
  ```
65
65
 
66
+ `node --version` must report **22.19.0 or newer**. If it is older — and
67
+ Ubuntu's own `apt` package is usually Node 18 — install a current one first:
68
+
69
+ ```bash
70
+ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
71
+ source ~/.bashrc
72
+ nvm install 24
73
+ nvm alias default 24
74
+ ```
75
+
76
+ **If the install fails with a permissions error**, npm's global directory belongs
77
+ to root. Move it somewhere you own, then install again:
78
+
79
+ ```bash
80
+ mkdir -p ~/.npm-global
81
+ npm config set prefix ~/.npm-global
82
+ export PATH="$HOME/.npm-global/bin:$PATH"
83
+ echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc
84
+
85
+ npm install -g @stemtrooper/learningcode
86
+ ```
87
+
88
+ The last line is what makes `learningcode` visible in future terminals; without
89
+ it you will think the install failed.
90
+
91
+ **Do not use `sudo npm install -g`.** It appears to work, then writes
92
+ root-owned files that break your next upgrade with a confusing error.
93
+
66
94
  ### Termux (Android)
67
95
 
68
96
  No root and no `proot-distro` needed.
@@ -167,7 +195,9 @@ A footer shows your remaining quota whenever you are connected to Spark:
167
195
  ## Troubleshooting
168
196
 
169
197
  **`Node 22.19.0 or newer is required`**
170
- Upgrade Node, then reinstall: `npm i -g @stemtrooper/learningcode`.
198
+ Upgrade Node, then reinstall: `npm i -g @stemtrooper/learningcode`. On Linux this
199
+ is usually the first thing to check, because Ubuntu ships Node 18 — see
200
+ [macOS / Linux](#macos--linux).
171
201
 
172
202
  **`No Spark API token configured`**
173
203
  You do not have a token yet, or it is not cached. Run `learningcode --login` to
@@ -288,6 +288,9 @@ async function main() {
288
288
  "it may not be a Spark token.\n",
289
289
  );
290
290
  }
291
+ // Keep Pi's stored credential and the environment in step. See the note
292
+ // beside saveProviderKey below.
293
+ await saveProviderKey(dir, PROVIDER_ID, token);
291
294
  if (resolved.shouldCache) await writeCachedToken(dir, token);
292
295
  } else if (explicitToken) {
293
296
  process.stderr.write(
@@ -320,11 +323,12 @@ async function main() {
320
323
 
321
324
  const childEnv = { ...process.env };
322
325
 
323
- // Pi reads provider credentials from auth.json before the environment, and
324
- // auth.json is keyed per provider. Storing the key under opencode-go alone
325
- // authenticates Go while leaving Zen credential-less, so its 111 pay-per-use
326
- // models never register. Zen and Go otherwise share OPENCODE_API_KEY, so
327
- // leaving the env var in place would expose both.
326
+ // Pi prefers a stored credential in auth.json over the `$SPARK_API_KEY` the
327
+ // provider config names, so leaving the token only in the environment lets the
328
+ // two drift apart: Pi authenticates with whatever auth.json holds while the
329
+ // /quota and /seats commands read the environment, and a stale entry then shows
330
+ // up as a 401 on those two but not on completions. Writing the token to
331
+ // auth.json as well makes every path use the same value whichever Pi prefers.
328
332
  const goKey = process.env.LEARNINGCODE_GO_KEY || process.env.OPENCODE_API_KEY;
329
333
  if (goKey && process.env.LEARNINGCODE_ALLOW_ZEN !== "1") {
330
334
  await saveProviderKey(dir, GO_PROVIDER, goKey);
package/lib/token.mjs CHANGED
@@ -101,7 +101,10 @@ export async function resolveToken(agentDir, explicit) {
101
101
  if (fromEnv) return { token: fromEnv.trim(), source: "environment" };
102
102
 
103
103
  const cached = await readCachedToken(agentDir);
104
- if (cached) return { token: cached, source: "cached" };
104
+ // A cached value that is not a Spark token is worse than no cache: it would be
105
+ // sent to Spark, rejected, and reported as a 401 that looks like a revoked
106
+ // token. Fall through and ask instead.
107
+ if (cached && isSparkToken(cached)) return { token: cached, source: "cached" };
105
108
 
106
109
  const entered = await promptSecret(
107
110
  "Paste your Spark API token (Bench > Issue / rotate token): ",
@@ -111,9 +114,23 @@ export async function resolveToken(agentDir, explicit) {
111
114
  }
112
115
 
113
116
  export function looksLikeToken(token) {
114
- return token.startsWith(TOKEN_PREFIX);
117
+ return isSparkToken(token);
115
118
  }
116
119
 
120
+ /**
121
+ * Whether a value could be a Spark token at all. Spark mints them as
122
+ * `spark_live_<secret>`, so anything else in the cache slot came from somewhere
123
+ * else and must not be sent to Spark.
124
+ *
125
+ * The length of the secret is deliberately not checked: Spark chooses it, and
126
+ * tightening this to today's value would start rejecting valid tokens the day
127
+ * that changes. Requiring something after the prefix is the safe minimum.
128
+ */
129
+ export const isSparkToken = (token) =>
130
+ typeof token === "string" &&
131
+ token.startsWith(TOKEN_PREFIX) &&
132
+ token.length > TOKEN_PREFIX.length;
133
+
117
134
  /** True when a token can be found without asking the student anything. */
118
135
  export async function hasToken(agentDir, explicit) {
119
136
  if (explicit) return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stemtrooper/learningcode",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "TLC Spark coding agent for students: Pi wired to the Spark OpenAI-compatible endpoint with per-student tokens, quota and seat-queue awareness.",
5
5
  "type": "module",
6
6
  "license": "MIT",