altalandi 0.6.0 → 0.6.1

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.mjs CHANGED
@@ -1,223 +1,223 @@
1
- // `altalandi login` — connect this computer to an Altalandi account once.
2
- //
3
- // Device login, the same pattern as `gh auth login`: the CLI asks the API for a
4
- // short code, the user opens altalandi.is/tengja, signs in or creates an
5
- // account, and approves. The CLI then receives a key for that account exactly
6
- // once and stores it in the user's config directory. The key never passes
7
- // through a chat, a prompt, the clipboard or shell history.
8
- //
9
- // Afterwards every `altalandi` call finds the key on its own, so an agent (the
10
- // Claude Code skill, a script) needs no environment variable at all.
11
- //
12
- // Zero dependencies, like the rest of the CLI.
13
-
14
- import { spawn } from "node:child_process";
15
- import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
16
- import { existsSync, readFileSync } from "node:fs";
17
- import { homedir, hostname } from "node:os";
18
- import path from "node:path";
19
-
20
- import { skillStatus, skillTarget, writeSkill } from "./update.mjs";
21
-
22
- export const DEFAULT_BASE_URL = "https://altalandi.is/api";
23
-
24
- /** Where the key is stored. ALTALANDI_CONFIG_DIR overrides (tests, CI). */
25
- export function configDir({ env = process.env, home = homedir(), platform = process.platform } = {}) {
26
- if (env.ALTALANDI_CONFIG_DIR) return env.ALTALANDI_CONFIG_DIR;
27
- if (platform === "win32") return path.join(env.APPDATA || path.join(home, "AppData", "Roaming"), "altalandi");
28
- return path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "altalandi");
29
- }
30
-
31
- export function credentialsFile(opts) {
32
- return path.join(configDir(opts), "credentials.json");
33
- }
34
-
35
- /** The stored credentials, or null. Never throws. */
36
- export function readCredentials(opts) {
37
- try {
38
- const data = JSON.parse(readFileSync(credentialsFile(opts), "utf8"));
39
- return data && typeof data.apiKey === "string" && data.apiKey ? data : null;
40
- } catch {
41
- return null;
42
- }
43
- }
44
-
45
- /** Key resolution order: explicit flag, ALTALANDI_API_KEY, stored login. */
46
- export function resolveKey({ flag, env = process.env, ...opts } = {}) {
47
- if (flag) return { key: flag, source: "flag" };
48
- const fromEnv = env.ALTALANDI_API_KEY || env.ALTALANDI_KEY;
49
- if (fromEnv) return { key: fromEnv, source: "env" };
50
- const stored = readCredentials({ env, ...opts });
51
- return stored ? { key: stored.apiKey, source: "login", username: stored.username } : { key: "", source: "none" };
52
- }
53
-
54
- async function saveCredentials(data, opts) {
55
- const dir = configDir(opts);
56
- await mkdir(dir, { recursive: true });
57
- const file = credentialsFile(opts);
58
- await writeFile(file, JSON.stringify(data, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
59
- try { await chmod(file, 0o600); } catch { /* Windows: the per-user profile directory is the protection */ }
60
- return file;
61
- }
62
-
63
- function clientName(env = process.env) {
64
- const where = (hostname() || "").split(".")[0].slice(0, 24);
65
- const who = env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT ? "Claude Code" : "altalandi CLI";
66
- return where ? `${who} á ${where}` : who;
67
- }
68
-
69
- function openBrowser(url) {
70
- try {
71
- const [cmd, args] = process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
72
- : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
73
- const child = spawn(cmd, args, { stdio: "ignore", detached: true, windowsHide: true });
74
- child.on("error", () => {});
75
- child.unref();
76
- return true;
77
- } catch {
78
- return false;
79
- }
80
- }
81
-
82
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
83
-
84
- async function postJson(fetchImpl, url, body) {
85
- const res = await fetchImpl(url, {
86
- method: "POST",
87
- headers: { "content-type": "application/json", accept: "application/json" },
88
- body: JSON.stringify(body),
89
- });
90
- let data = {};
91
- try { data = await res.json(); } catch { /* keep {} */ }
92
- return { status: res.status, data };
93
- }
94
-
95
- /**
96
- * Run the device login. Returns an exit code.
97
- * 0 logged in · 1 failed or expired · 2 bad usage
98
- */
99
- export async function runLogin(flags = {}, io = {}) {
100
- const env = io.env || process.env;
101
- const fetchImpl = io.fetch || globalThis.fetch;
102
- const log = io.log || ((m) => console.error(m));
103
- const base = (flags.baseUrl || env.ALTALANDI_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, "");
104
- const wait = io.sleep || sleep;
105
-
106
- let start;
107
- try {
108
- start = await postJson(fetchImpl, `${base}/v1/device/start`, { clientName: clientName(env) });
109
- } catch (e) {
110
- log(`Náði ekki sambandi við Altalandi (${e.message}).`);
111
- return 1;
112
- }
113
- if (start.status !== 200 || !start.data.deviceCode) {
114
- log(`Innskráning fór ekki af stað: ${start.data.detail || `HTTP ${start.status}`}`);
115
- return 1;
116
- }
117
- const { deviceCode, userCode, verificationUriComplete, verificationUri, interval = 3, expiresIn = 600 } = start.data;
118
-
119
- log("");
120
- log(" Tengdu þessa tölvu við Altalandi-aðganginn þinn:");
121
- log("");
122
- log(` 1. Opnaðu ${verificationUriComplete}`);
123
- log(` 2. Skráðu þig inn eða stofnaðu aðgang (500 kr. prufuinneign fylgir)`);
124
- log(` 3. Staðfestu kóðann ${userCode}`);
125
- log("");
126
- log(` (Connect this computer: open the link, sign in or sign up, confirm the code.`);
127
- log(` Code page: ${verificationUri})`);
128
- log("");
129
- if (!flags.noBrowser && !io.noBrowser) openBrowser(verificationUriComplete);
130
- log(" Bíð eftir staðfestingu…");
131
-
132
- const deadline = Date.now() + expiresIn * 1000;
133
- let result = null;
134
- while (Date.now() < deadline) {
135
- await wait(Math.max(1, interval) * 1000);
136
- let poll;
137
- try {
138
- poll = await postJson(fetchImpl, `${base}/v1/device/token`, { deviceCode });
139
- } catch {
140
- continue; // transient network error: keep waiting until the code expires
141
- }
142
- if (poll.status === 200 && poll.data.status === "approved") { result = poll.data; break; }
143
- if (poll.status === 200 && poll.data.status === "pending") continue;
144
- if (poll.status === 429) { await wait(10_000); continue; }
145
- log(` Kóðinn rann út eða var hafnað. Keyrðu \`altalandi login\` aftur.`);
146
- return 1;
147
- }
148
- if (!result) {
149
- log(` Kóðinn rann út. Keyrðu \`altalandi login\` aftur.`);
150
- return 1;
151
- }
152
-
153
- const file = await saveCredentials({
154
- apiKey: result.apiKey,
155
- username: result.username,
156
- baseUrl: base === DEFAULT_BASE_URL ? undefined : base,
157
- savedAt: new Date().toISOString(),
158
- }, { env });
159
- log("");
160
- log(` ✓ Tengt sem ${result.username}. Lykillinn er vistaður í ${file}`);
161
-
162
- // Install or refresh the Claude Code skill — the point of logging in for most
163
- // people. Only where Claude Code exists; never create ~/.claude for someone who
164
- // does not use it. --no-skill opts out.
165
- if (!flags.noSkill) {
166
- const target = skillTarget({ env });
167
- const status = await skillStatus({ target });
168
- if (status === "missing" || status === "outdated") {
169
- const { file: skillFile, backup } = await writeSkill({ target });
170
- log(` ✓ Claude Code-hæfnin „altalandi" sett upp: ${skillFile}${backup ? ` (eldri útgáfa afrituð í ${backup})` : ""}`);
171
- } else if (status === "current") {
172
- log(` ✓ Claude Code-hæfnin „altalandi" er þegar uppfærð.`);
173
- } else if (status === "absent") {
174
- log(` (Claude Code fannst ekki á þessari tölvu, svo hæfnin var ekki sett upp.)`);
175
- }
176
- }
177
- log("");
178
- log(` Prófaðu: altalandi "Make sure to reach out to us today."`);
179
- log(` Eða segðu Claude: „Skrifaðu þetta á góðri íslensku með Altalandi."`);
180
- return 0;
181
- }
182
-
183
- export async function runLogout(io = {}) {
184
- const env = io.env || process.env;
185
- const log = io.log || ((m) => console.error(m));
186
- const file = credentialsFile({ env });
187
- if (!existsSync(file)) { log("Engin vistuð innskráning."); return 0; }
188
- await rm(file, { force: true });
189
- log(`Innskráning fjarlægð (${file}). Lykillinn sjálfur er enn virkur þar til þú afturkallar hann á https://altalandi.is/portal`);
190
- return 0;
191
- }
192
-
193
- export async function runWhoami(flags = {}, io = {}) {
194
- const env = io.env || process.env;
195
- const fetchImpl = io.fetch || globalThis.fetch;
196
- const out = io.out || ((m) => console.log(m));
197
- const log = io.log || ((m) => console.error(m));
198
- const { key, source } = resolveKey({ flag: flags.key, env });
199
- if (!key) { log("Ekki innskráð(ur). Keyrðu: npx altalandi login"); return 2; }
200
- const stored = readCredentials({ env });
201
- const base = (flags.baseUrl || env.ALTALANDI_BASE_URL || stored?.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
202
- let res;
203
- try {
204
- res = await fetchImpl(`${base}/v1/me`, { headers: { authorization: `Bearer ${key}`, accept: "application/json" } });
205
- } catch (e) {
206
- log(`Náði ekki sambandi við Altalandi (${e.message}).`);
207
- return 1;
208
- }
209
- if (res.status === 401) { log("Lykillinn er ógildur eða afturkallaður. Keyrðu: npx altalandi login"); return 1; }
210
- if (!res.ok) { log(`Villa: HTTP ${res.status}`); return 1; }
211
- const me = await res.json();
212
- if (flags.json) out(JSON.stringify({ ...me, keySource: source }, null, 2));
213
- else {
214
- const { formatInt, formatIsk } = await import("./report.mjs");
215
- const free = me.freeUnitsLeftToday != null && me.freeDailyUnits != null
216
- ? `ókeypis eftir í dag ${formatInt(me.freeUnitsLeftToday)} af ${formatInt(me.freeDailyUnits)} einingum`
217
- : me.freeDailyUnits ? `ókeypis ${formatInt(me.freeDailyUnits)} einingar á dag` : "";
218
- out([me.username ?? "(lykill án aðgangs)",
219
- `inneign ${me.balanceIsk != null ? formatIsk(me.balanceIsk) : "—"}`,
220
- free, `lykill ${me.keyId} (${source})`].filter(Boolean).join(" · "));
221
- }
222
- return 0;
223
- }
1
+ // `altalandi login` — connect this computer to an Altalandi account once.
2
+ //
3
+ // Device login, the same pattern as `gh auth login`: the CLI asks the API for a
4
+ // short code, the user opens altalandi.is/tengja, signs in or creates an
5
+ // account, and approves. The CLI then receives a key for that account exactly
6
+ // once and stores it in the user's config directory. The key never passes
7
+ // through a chat, a prompt, the clipboard or shell history.
8
+ //
9
+ // Afterwards every `altalandi` call finds the key on its own, so an agent (the
10
+ // Claude Code skill, a script) needs no environment variable at all.
11
+ //
12
+ // Zero dependencies, like the rest of the CLI.
13
+
14
+ import { spawn } from "node:child_process";
15
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { homedir, hostname } from "node:os";
18
+ import path from "node:path";
19
+
20
+ import { skillStatus, skillTarget, writeSkill } from "./update.mjs";
21
+
22
+ export const DEFAULT_BASE_URL = "https://altalandi.is/api";
23
+
24
+ /** Where the key is stored. ALTALANDI_CONFIG_DIR overrides (tests, CI). */
25
+ export function configDir({ env = process.env, home = homedir(), platform = process.platform } = {}) {
26
+ if (env.ALTALANDI_CONFIG_DIR) return env.ALTALANDI_CONFIG_DIR;
27
+ if (platform === "win32") return path.join(env.APPDATA || path.join(home, "AppData", "Roaming"), "altalandi");
28
+ return path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "altalandi");
29
+ }
30
+
31
+ export function credentialsFile(opts) {
32
+ return path.join(configDir(opts), "credentials.json");
33
+ }
34
+
35
+ /** The stored credentials, or null. Never throws. */
36
+ export function readCredentials(opts) {
37
+ try {
38
+ const data = JSON.parse(readFileSync(credentialsFile(opts), "utf8"));
39
+ return data && typeof data.apiKey === "string" && data.apiKey ? data : null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /** Key resolution order: explicit flag, ALTALANDI_API_KEY, stored login. */
46
+ export function resolveKey({ flag, env = process.env, ...opts } = {}) {
47
+ if (flag) return { key: flag, source: "flag" };
48
+ const fromEnv = env.ALTALANDI_API_KEY || env.ALTALANDI_KEY;
49
+ if (fromEnv) return { key: fromEnv, source: "env" };
50
+ const stored = readCredentials({ env, ...opts });
51
+ return stored ? { key: stored.apiKey, source: "login", username: stored.username } : { key: "", source: "none" };
52
+ }
53
+
54
+ async function saveCredentials(data, opts) {
55
+ const dir = configDir(opts);
56
+ await mkdir(dir, { recursive: true });
57
+ const file = credentialsFile(opts);
58
+ await writeFile(file, JSON.stringify(data, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
59
+ try { await chmod(file, 0o600); } catch { /* Windows: the per-user profile directory is the protection */ }
60
+ return file;
61
+ }
62
+
63
+ function clientName(env = process.env) {
64
+ const where = (hostname() || "").split(".")[0].slice(0, 24);
65
+ const who = env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT ? "Claude Code" : "altalandi CLI";
66
+ return where ? `${who} á ${where}` : who;
67
+ }
68
+
69
+ function openBrowser(url) {
70
+ try {
71
+ const [cmd, args] = process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
72
+ : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
73
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true, windowsHide: true });
74
+ child.on("error", () => {});
75
+ child.unref();
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
83
+
84
+ async function postJson(fetchImpl, url, body) {
85
+ const res = await fetchImpl(url, {
86
+ method: "POST",
87
+ headers: { "content-type": "application/json", accept: "application/json" },
88
+ body: JSON.stringify(body),
89
+ });
90
+ let data = {};
91
+ try { data = await res.json(); } catch { /* keep {} */ }
92
+ return { status: res.status, data };
93
+ }
94
+
95
+ /**
96
+ * Run the device login. Returns an exit code.
97
+ * 0 logged in · 1 failed or expired · 2 bad usage
98
+ */
99
+ export async function runLogin(flags = {}, io = {}) {
100
+ const env = io.env || process.env;
101
+ const fetchImpl = io.fetch || globalThis.fetch;
102
+ const log = io.log || ((m) => console.error(m));
103
+ const base = (flags.baseUrl || env.ALTALANDI_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, "");
104
+ const wait = io.sleep || sleep;
105
+
106
+ let start;
107
+ try {
108
+ start = await postJson(fetchImpl, `${base}/v1/device/start`, { clientName: clientName(env) });
109
+ } catch (e) {
110
+ log(`Náði ekki sambandi við Altalandi (${e.message}).`);
111
+ return 1;
112
+ }
113
+ if (start.status !== 200 || !start.data.deviceCode) {
114
+ log(`Innskráning fór ekki af stað: ${start.data.detail || `HTTP ${start.status}`}`);
115
+ return 1;
116
+ }
117
+ const { deviceCode, userCode, verificationUriComplete, verificationUri, interval = 3, expiresIn = 600 } = start.data;
118
+
119
+ log("");
120
+ log(" Tengdu þessa tölvu við Altalandi-aðganginn þinn:");
121
+ log("");
122
+ log(` 1. Opnaðu ${verificationUriComplete}`);
123
+ log(` 2. Skráðu þig inn eða stofnaðu aðgang (500 kr. prufuinneign fylgir)`);
124
+ log(` 3. Staðfestu kóðann ${userCode}`);
125
+ log("");
126
+ log(` (Connect this computer: open the link, sign in or sign up, confirm the code.`);
127
+ log(` Code page: ${verificationUri})`);
128
+ log("");
129
+ if (!flags.noBrowser && !io.noBrowser) openBrowser(verificationUriComplete);
130
+ log(" Bíð eftir staðfestingu…");
131
+
132
+ const deadline = Date.now() + expiresIn * 1000;
133
+ let result = null;
134
+ while (Date.now() < deadline) {
135
+ await wait(Math.max(1, interval) * 1000);
136
+ let poll;
137
+ try {
138
+ poll = await postJson(fetchImpl, `${base}/v1/device/token`, { deviceCode });
139
+ } catch {
140
+ continue; // transient network error: keep waiting until the code expires
141
+ }
142
+ if (poll.status === 200 && poll.data.status === "approved") { result = poll.data; break; }
143
+ if (poll.status === 200 && poll.data.status === "pending") continue;
144
+ if (poll.status === 429) { await wait(10_000); continue; }
145
+ log(` Kóðinn rann út eða var hafnað. Keyrðu \`altalandi login\` aftur.`);
146
+ return 1;
147
+ }
148
+ if (!result) {
149
+ log(` Kóðinn rann út. Keyrðu \`altalandi login\` aftur.`);
150
+ return 1;
151
+ }
152
+
153
+ const file = await saveCredentials({
154
+ apiKey: result.apiKey,
155
+ username: result.username,
156
+ baseUrl: base === DEFAULT_BASE_URL ? undefined : base,
157
+ savedAt: new Date().toISOString(),
158
+ }, { env });
159
+ log("");
160
+ log(` ✓ Tengt sem ${result.username}. Lykillinn er vistaður í ${file}`);
161
+
162
+ // Install or refresh the Claude Code skill — the point of logging in for most
163
+ // people. Only where Claude Code exists; never create ~/.claude for someone who
164
+ // does not use it. --no-skill opts out.
165
+ if (!flags.noSkill) {
166
+ const target = skillTarget({ env });
167
+ const status = await skillStatus({ target });
168
+ if (status === "missing" || status === "outdated") {
169
+ const { file: skillFile, backup } = await writeSkill({ target });
170
+ log(` ✓ Claude Code-hæfnin „altalandi" sett upp: ${skillFile}${backup ? ` (eldri útgáfa afrituð í ${backup})` : ""}`);
171
+ } else if (status === "current") {
172
+ log(` ✓ Claude Code-hæfnin „altalandi" er þegar uppfærð.`);
173
+ } else if (status === "absent") {
174
+ log(` (Claude Code fannst ekki á þessari tölvu, svo hæfnin var ekki sett upp.)`);
175
+ }
176
+ }
177
+ log("");
178
+ log(` Prófaðu: altalandi "Make sure to reach out to us today."`);
179
+ log(` Eða segðu Claude: „Skrifaðu þetta á góðri íslensku með Altalandi."`);
180
+ return 0;
181
+ }
182
+
183
+ export async function runLogout(io = {}) {
184
+ const env = io.env || process.env;
185
+ const log = io.log || ((m) => console.error(m));
186
+ const file = credentialsFile({ env });
187
+ if (!existsSync(file)) { log("Engin vistuð innskráning."); return 0; }
188
+ await rm(file, { force: true });
189
+ log(`Innskráning fjarlægð (${file}). Lykillinn sjálfur er enn virkur þar til þú afturkallar hann á https://altalandi.is/stjornbord/forritarar`);
190
+ return 0;
191
+ }
192
+
193
+ export async function runWhoami(flags = {}, io = {}) {
194
+ const env = io.env || process.env;
195
+ const fetchImpl = io.fetch || globalThis.fetch;
196
+ const out = io.out || ((m) => console.log(m));
197
+ const log = io.log || ((m) => console.error(m));
198
+ const { key, source } = resolveKey({ flag: flags.key, env });
199
+ if (!key) { log("Ekki innskráð(ur). Keyrðu: npx altalandi login"); return 2; }
200
+ const stored = readCredentials({ env });
201
+ const base = (flags.baseUrl || env.ALTALANDI_BASE_URL || stored?.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
202
+ let res;
203
+ try {
204
+ res = await fetchImpl(`${base}/v1/me`, { headers: { authorization: `Bearer ${key}`, accept: "application/json" } });
205
+ } catch (e) {
206
+ log(`Náði ekki sambandi við Altalandi (${e.message}).`);
207
+ return 1;
208
+ }
209
+ if (res.status === 401) { log("Lykillinn er ógildur eða afturkallaður. Keyrðu: npx altalandi login"); return 1; }
210
+ if (!res.ok) { log(`Villa: HTTP ${res.status}`); return 1; }
211
+ const me = await res.json();
212
+ if (flags.json) out(JSON.stringify({ ...me, keySource: source }, null, 2));
213
+ else {
214
+ const { formatInt, formatIsk } = await import("./report.mjs");
215
+ const free = me.freeUnitsLeftToday != null && me.freeDailyUnits != null
216
+ ? `ókeypis eftir í dag ${formatInt(me.freeUnitsLeftToday)} af ${formatInt(me.freeDailyUnits)} einingum`
217
+ : me.freeDailyUnits ? `ókeypis ${formatInt(me.freeDailyUnits)} einingar á dag` : "";
218
+ out([me.username ?? "(lykill án aðgangs)",
219
+ `inneign ${me.balanceIsk != null ? formatIsk(me.balanceIsk) : "—"}`,
220
+ free, `lykill ${me.keyId} (${source})`].filter(Boolean).join(" · "));
221
+ }
222
+ return 0;
223
+ }