progmaskinen 0.0.0-stage → 1.0.0-beta.2

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/LICENSE ADDED
@@ -0,0 +1,11 @@
1
+ Progmaskinen
2
+ © Jakob Busk. Alle rettigheder forbeholdes.
3
+
4
+ Du må bruge Progmaskinen til din egen personlige eksamenslæsning.
5
+
6
+ Du må ikke:
7
+ - bruge den i en institution eller i undervisning for andre
8
+ - sælge den eller dele den videre mod betaling
9
+ - bruge den kommercielt på nogen måde
10
+
11
+ Progmaskinen leveres, som den er, uden nogen form for garanti. Brugen sker på eget ansvar.
package/README.md CHANGED
@@ -1,3 +1,41 @@
1
- # Temporary Holding Version
1
+ # Sådan henter du Progmaskinen
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Progmaskinen er træning til programmeringseksamen på 1. semester (JavaScript, HTML og CSS). Her henter du den.
4
+
5
+ ## Det skal du have først
6
+
7
+ - **Node.js** (nyeste LTS-version): https://nodejs.org
8
+ - **Claude Code**: https://claude.com/claude-code
9
+ - **Claude Pro** (eller en større plan). Den gratis Claude-plan kan ikke bruges med Claude Code.
10
+
11
+ ## Hent den
12
+
13
+ Åbn en terminal (macOS: Terminal. Windows: PowerShell) og skriv:
14
+
15
+ ```
16
+ npx progmaskinen
17
+ ```
18
+
19
+ Programmet spørger, hvor Progmaskinen skal ligge. Tryk Enter for at bruge forslaget (`Dokumenter/Progmaskinen`). Bagefter åbner din browser af sig selv, og der står, hvad du skal gøre næste gang.
20
+
21
+ Virker den ikke, kan du bruge denne i stedet:
22
+
23
+ ```
24
+ npx github:jakob-busk/progmaskinen-hent
25
+ ```
26
+
27
+ Den kræver, at git er installeret (uden git fejler den med "spawn git ENOENT"). Har du ikke git, så brug denne adresse, som ikke kræver git:
28
+
29
+ ```
30
+ npx https://github.com/jakob-busk/progmaskinen-hent/tarball/main
31
+ ```
32
+
33
+ Eller hent zip-filen under [Releases](https://github.com/jakob-busk/progmaskinen-hent/releases) og pak den ud i Dokumenter.
34
+
35
+ ## Har du den allerede?
36
+
37
+ Kører du kommandoen igen, rører den ikke din mappe. Den siger bare, at du allerede har Progmaskinen. Opdatering sker inde i programmet: skriv "opdater" til Claude.
38
+
39
+ ## Licens
40
+
41
+ Alle rettigheder forbeholdes. Se [LICENSE](LICENSE).
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // Indgangen til installeren. Skrevet i gammel, enkel syntaks, så den kan give en dansk besked, selv på en for gammel Node.
3
+ var dele = process.versions.node.split('.').map(Number);
4
+ if (dele[0] < 20 || (dele[0] === 20 && dele[1] < 11)) {
5
+ console.error('Din Node.js (' + process.versions.node + ') er for gammel. Progmaskinen kræver Node.js 20.11 eller nyere.');
6
+ console.error('Hent den nyeste LTS-version på https://nodejs.org, installér den, og kør kommandoen igen.');
7
+ process.exit(1);
8
+ }
9
+ import('../lib/installer.mjs').then(function (m) {
10
+ return m.koer({ argv: process.argv.slice(2) }).then(function (kode) {
11
+ process.exitCode = kode;
12
+ });
13
+ });
@@ -0,0 +1,312 @@
1
+ // Installeren: henter den nyeste udgivelse af Progmaskinen fra GitHub, verificerer den og pakker den ud.
2
+ // Kun node:-moduler, ingen afhængigheder. Rører aldrig en eksisterende Progmaskinen-mappe (elev/ er den studerendes).
3
+
4
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, openSync, closeSync, readdirSync, readFileSync, renameSync, rmSync, rmdirSync, statSync, writeFileSync } from 'node:fs';
5
+ import { spawn, spawnSync } from 'node:child_process';
6
+ import { createHash } from 'node:crypto';
7
+ import { homedir, tmpdir } from 'node:os';
8
+ import { basename, dirname, join, resolve, sep } from 'node:path';
9
+ import { createInterface } from 'node:readline';
10
+
11
+ export const UDGIVELSER = 'https://api.github.com/repos/jakob-busk/progmaskinen-hent/releases?per_page=30';
12
+ const MAPPENAVN = 'Progmaskinen';
13
+ const TMP_PRAEFIKS = '.progmaskinen-hent-';
14
+
15
+ class InstallationsFejl extends Error {}
16
+ const fejl = (tekst) => new InstallationsFejl(tekst);
17
+
18
+ // ---------- semver (en lille kopi af produktets kerne/semver.mjs; installeren har ingen afhængigheder) ----------
19
+ const TAL = '(0|[1-9]\\d*)';
20
+ const LED = '(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)';
21
+ const SEMVER = new RegExp(`^${TAL}\\.${TAL}\\.${TAL}(?:-(${LED}(?:\\.${LED})*))?$`);
22
+ export const erSemver = (v) => typeof v === 'string' && SEMVER.test(v);
23
+ function dele(v) {
24
+ const m = SEMVER.exec(v);
25
+ return { tal: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4]?.split('.') ?? [] };
26
+ }
27
+ export function sammenlignSemver(a, b) {
28
+ const [x, y] = [dele(a), dele(b)];
29
+ for (let i = 0; i < 3; i++) if (x.tal[i] !== y.tal[i]) return x.tal[i] - y.tal[i];
30
+ if (!x.pre.length || !y.pre.length) return y.pre.length - x.pre.length;
31
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
32
+ const [p, q] = [x.pre[i], y.pre[i]];
33
+ if (p === undefined) return -1;
34
+ if (q === undefined) return 1;
35
+ if (p === q) continue;
36
+ const [pt, qt] = [/^\d+$/.test(p), /^\d+$/.test(q)];
37
+ if (pt && qt) return Number(p) - Number(q);
38
+ if (pt !== qt) return pt ? -1 : 1;
39
+ return p < q ? -1 : 1;
40
+ }
41
+ return 0;
42
+ }
43
+
44
+ /** Den højeste udgivelse (prereleases og kladder kun, hvis det udtrykkeligt bedes om). */
45
+ export function nyesteUdgivelse(liste, { prerelease = false } = {}) {
46
+ if (!Array.isArray(liste)) return undefined;
47
+ const version = (u) => u.tag_name?.replace(/^v/, '');
48
+ return liste
49
+ .filter((u) => !u.draft && erSemver(version(u)) && (prerelease || !u.prerelease))
50
+ .sort((a, b) => sammenlignSemver(version(b), version(a)))[0];
51
+ }
52
+
53
+ export const nodeVersionOk = (v) => {
54
+ const [major, minor] = v.split('.').map(Number);
55
+ return major > 20 || (major === 20 && minor >= 11);
56
+ };
57
+
58
+ // ---------- hjælpere ----------
59
+ const sha256 = (data) => createHash('sha256').update(data).digest('hex');
60
+
61
+ export function standardMappe(env = process.env, platform = process.platform) {
62
+ const hjem = (platform === 'win32' ? env.USERPROFILE : env.HOME) || homedir();
63
+ return join(hjem, 'Documents', MAPPENAVN);
64
+ }
65
+
66
+ function laesArgumenter(argv) {
67
+ const valg = { prerelease: false, mappe: undefined, hjaelp: false };
68
+ for (let i = 0; i < argv.length; i++) {
69
+ const a = argv[i];
70
+ if (a === '--prerelease') valg.prerelease = true;
71
+ else if (a === '--mappe') valg.mappe = argv[++i];
72
+ else if (a.startsWith('--mappe=')) valg.mappe = a.slice('--mappe='.length);
73
+ else if (a === '--hjaelp' || a === '--hjælp' || a === '-h' || a === '--help') valg.hjaelp = true;
74
+ else throw fejl(`Jeg kender ikke "${a}". Skriv --hjælp for at se, hvad du kan skrive.`);
75
+ }
76
+ if (valg.mappe !== undefined && !valg.mappe) throw fejl('--mappe mangler en mappe, fx --mappe "C:\\Studie\\Progmaskinen".');
77
+ return valg;
78
+ }
79
+
80
+ const HJAELP = `Progmaskinen: henter og installerer Progmaskinen.
81
+
82
+ npx progmaskinen hent den nyeste udgivelse
83
+ npx progmaskinen --mappe <sti> læg den i en anden mappe end Dokumenter/Progmaskinen
84
+ npx progmaskinen --prerelease tag også forhåndsudgivelser (kun til test)
85
+ `;
86
+
87
+ /**
88
+ * Smider alt, der allerede ligger i stdin (fx et linjeskift fra npx' "Ok to proceed? (y)"), før et spørgsmål stilles,
89
+ * så spørgsmålet altid venter på en linje, brugeren skriver efter at have set det.
90
+ */
91
+ function tomStdin(stdin, ms = 200) {
92
+ return new Promise((ok) => {
93
+ const kast = () => {};
94
+ stdin.on('data', kast);
95
+ stdin.resume();
96
+ setTimeout(() => {
97
+ stdin.off('data', kast);
98
+ stdin.pause();
99
+ ok();
100
+ }, ms);
101
+ });
102
+ }
103
+
104
+ async function spoerg(stdin, stdout, tekst) {
105
+ if (stdin.isTTY) await tomStdin(stdin);
106
+ return new Promise((ok) => {
107
+ const rl = createInterface({ input: stdin, output: stdout });
108
+ let svaret = false;
109
+ // Løftet løses først med svaret; rl.close() udløser 'close', som ellers ville løse det med '' først.
110
+ rl.question(tekst, (svar) => {
111
+ svaret = true;
112
+ ok(svar.trim());
113
+ rl.close();
114
+ });
115
+ rl.on('close', () => {
116
+ if (!svaret) ok(''); // stdin blev lukket (EOF) uden et svar
117
+ });
118
+ });
119
+ }
120
+
121
+ function udvidSti(sti, env) {
122
+ const hjem = (process.platform === 'win32' ? env.USERPROFILE : env.HOME) || homedir();
123
+ const uden = sti.replace(/^["']|["']$/g, '');
124
+ return resolve(uden === '~' ? hjem : uden.startsWith('~/') || uden.startsWith('~\\') ? join(hjem, uden.slice(2)) : uden);
125
+ }
126
+
127
+ async function hent(url, ventMs) {
128
+ let svar;
129
+ try {
130
+ svar = await fetch(url, { headers: { 'User-Agent': 'progmaskinen-installer', Accept: 'application/vnd.github+json' }, signal: AbortSignal.timeout(ventMs) });
131
+ } catch (f) {
132
+ throw fejl(`Kunne ikke hente Progmaskinen (${f.name === 'TimeoutError' ? 'det tog for lang tid' : f.cause?.code ?? f.message}). Tjek din internetforbindelse, og prøv igen.`);
133
+ }
134
+ if (svar.status === 403 || svar.status === 429) throw fejl('GitHub siger, at der er spurgt for mange gange lige nu. Vent lidt, og prøv igen om en halv times tid.');
135
+ if (!svar.ok) throw fejl(`Kunne ikke hente Progmaskinen (GitHub svarede ${svar.status}). Prøv igen senere.`);
136
+ return svar;
137
+ }
138
+
139
+ async function hentUdgivelse(url, prerelease, skriv) {
140
+ const liste = await (await hent(url, 20000)).json();
141
+ const udgivelse = nyesteUdgivelse(liste, { prerelease });
142
+ if (!udgivelse) throw fejl('Der er endnu ingen udgivelse af Progmaskinen at hente. Prøv igen senere.');
143
+ const zip = udgivelse.assets?.find((a) => a.name.endsWith('.zip'));
144
+ if (!zip) throw fejl('Udgivelsen har ingen zip-fil. Prøv igen senere.');
145
+ const kontrol = udgivelse.assets.find((a) => a.name === `${zip.name}.sha256`);
146
+ if (!kontrol) throw fejl('Udgivelsen har ingen kontrolsum (SHA256), så den installeres ikke.');
147
+ const version = udgivelse.tag_name.replace(/^v/, '');
148
+ skriv(`Henter Progmaskinen ${version} ...`);
149
+ const forventet = (await (await hent(kontrol.browser_download_url, 20000)).text()).match(/\b[0-9a-f]{64}\b/i)?.[0]?.toLowerCase();
150
+ let data;
151
+ try {
152
+ data = Buffer.from(await (await hent(zip.browser_download_url, 180000)).arrayBuffer());
153
+ } catch (f) {
154
+ if (f instanceof InstallationsFejl) throw f;
155
+ throw fejl(`Hentningen blev afbrudt (${f.cause?.code ?? f.message}). Tjek din internetforbindelse, og prøv igen.`);
156
+ }
157
+ if (!forventet || sha256(data) !== forventet) throw fejl('Den hentede fil passer ikke med sin kontrolsum (SHA256), så den installeres ikke. Prøv igen senere.');
158
+ return { data, version };
159
+ }
160
+
161
+ function pakUd(zip, til) {
162
+ const r = spawnSync('tar', ['-xf', zip, '-C', til], { encoding: 'utf8' });
163
+ if (r.error || r.status !== 0) throw fejl(`Kunne ikke pakke Progmaskinen ud (${r.stderr?.trim() || r.error?.message || 'tar mangler'}). Du kan i stedet hente zip-filen på https://github.com/jakob-busk/progmaskinen-hent/releases og pakke den ud selv.`);
164
+ }
165
+
166
+ /** Finder produktmappen i det udpakkede (zip-filen har én mappe) og kontrollerer den mod sit manifest. */
167
+ function findOgVerificer(udpakket) {
168
+ const under = readdirSync(udpakket).filter((f) => statSync(join(udpakket, f)).isDirectory());
169
+ const rod = under.length === 1 ? join(udpakket, under[0]) : udpakket;
170
+ if (!['kerne', 'scripts', 'version.json', 'manifest.json'].every((f) => existsSync(join(rod, f)))) throw fejl('Den hentede udgivelse ligner ikke Progmaskinen, så den installeres ikke.');
171
+ let manifest;
172
+ try {
173
+ manifest = JSON.parse(readFileSync(join(rod, 'manifest.json'), 'utf8'));
174
+ } catch {
175
+ throw fejl('Udgivelsens manifest kan ikke læses, så den installeres ikke.');
176
+ }
177
+ for (const [fil, sum] of Object.entries(manifest.filer ?? {})) {
178
+ const sti = join(rod, ...fil.split('/'));
179
+ if (!existsSync(sti) || sha256(readFileSync(sti)) !== sum) throw fejl(`Filen ${fil} i udgivelsen mangler eller passer ikke med sin kontrolsum, så den installeres ikke.`);
180
+ }
181
+ return rod;
182
+ }
183
+
184
+ const erTom = (mappe) => readdirSync(mappe).length === 0;
185
+ const harProgmaskinen = (mappe) => existsSync(join(mappe, 'elev')) || existsSync(join(mappe, 'version.json')) || existsSync(join(mappe, 'kerne'));
186
+
187
+ function aabnMappen(mappe) {
188
+ const [kommando, args] = process.platform === 'win32' ? ['explorer', [mappe]] : process.platform === 'darwin' ? ['open', [mappe]] : ['xdg-open', [mappe]];
189
+ spawn(kommando, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
190
+ }
191
+
192
+ /** Starter onboarding-serveren (som åbner browseren selv), og venter kort på, at den melder sin adresse. */
193
+ async function startOnboarding(mappe, env) {
194
+ const logSti = join(tmpdir(), `progmaskinen-onboarding-${process.pid}.log`);
195
+ const ud = openSync(logSti, 'w');
196
+ const barn = spawn(process.execPath, [join('scripts', 'onboarding.mjs')], { cwd: mappe, env, detached: true, stdio: ['ignore', ud, ud], windowsHide: true });
197
+ barn.on('error', () => {});
198
+ barn.unref();
199
+ closeSync(ud);
200
+ if (env.PROGMASKINEN_INSTALLER_PID_FIL) writeFileSync(env.PROGMASKINEN_INSTALLER_PID_FIL, String(barn.pid));
201
+ let url;
202
+ for (let i = 0; i < 100 && !url; i++) {
203
+ await new Promise((ok) => setTimeout(ok, 100));
204
+ url = readFileSync(logSti, 'utf8').match(/https?:\/\/localhost:\d+\//)?.[0];
205
+ }
206
+ try {
207
+ rmSync(logSti, { force: true });
208
+ } catch {
209
+ /* på Windows kan filen være åben endnu; den ligger i den midlertidige mappe */
210
+ }
211
+ return url;
212
+ }
213
+
214
+ /**
215
+ * Kører installationen. Returnerer exit-kode. Under test kan alt styres udefra via valgene.
216
+ * Uden TTY (fx i tests) bruges forslaget til mappen uden at spørge.
217
+ */
218
+ export async function koer({ argv = [], env = process.env, stdin = process.stdin, stdout = process.stdout, skriv = (t) => stdout.write(`${t}\n`) } = {}) {
219
+ let tmp;
220
+ try {
221
+ const valg = laesArgumenter(argv);
222
+ if (valg.hjaelp) {
223
+ skriv(HJAELP.trimEnd());
224
+ return 0;
225
+ }
226
+ if (!nodeVersionOk(process.versions.node)) throw fejl(`Din Node.js (${process.versions.node}) er for gammel. Progmaskinen kræver Node.js 20.11 eller nyere. Hent den nyeste LTS-version på https://nodejs.org.`);
227
+
228
+ // 1. Hvor skal den ligge?
229
+ const forslag = standardMappe(env);
230
+ let mappe;
231
+ if (valg.mappe) mappe = udvidSti(valg.mappe, env);
232
+ else if (stdin.isTTY && stdout.isTTY) {
233
+ let tekst = `Progmaskinen lægges i ${forslag}\nTryk Enter for at bruge den, eller skriv en anden mappe: `;
234
+ for (;;) {
235
+ const svar = await spoerg(stdin, stdout, tekst);
236
+ if (!svar) {
237
+ mappe = forslag;
238
+ break;
239
+ }
240
+ const valgt = udvidSti(svar, env);
241
+ skriv(`Du har valgt: ${valgt}`);
242
+ if (valgt === forslag) {
243
+ mappe = valgt;
244
+ break;
245
+ }
246
+ const ok = (await spoerg(stdin, stdout, 'Er det rigtigt? (j/n) ')).toLowerCase();
247
+ if (ok === 'j' || ok === 'ja') {
248
+ mappe = valgt;
249
+ break;
250
+ }
251
+ tekst = 'Skriv den mappe, du vil bruge (eller tryk Enter for at bruge forslaget): ';
252
+ }
253
+ } else {
254
+ mappe = forslag;
255
+ skriv(`Progmaskinen lægges i ${mappe}.`);
256
+ }
257
+
258
+ // 2. Findes den allerede? Så røres intet.
259
+ if (existsSync(mappe)) {
260
+ if (!statSync(mappe).isDirectory()) throw fejl(`${mappe} er en fil, ikke en mappe. Vælg en anden mappe med --mappe.`);
261
+ if (harProgmaskinen(mappe)) {
262
+ skriv(`Du har allerede Progmaskinen her: ${mappe}`);
263
+ skriv('Der er ikke ændret noget, og dit fremskridt er urørt. Opdatering sker inde i programmet: skriv "opdater" til Claude.');
264
+ if (stdin.isTTY && stdout.isTTY) {
265
+ const svar = (await spoerg(stdin, stdout, 'Vil du åbne mappen? (j/N) ')).toLowerCase();
266
+ if (svar === 'j' || svar === 'ja') aabnMappen(mappe);
267
+ }
268
+ return 0;
269
+ }
270
+ if (!erTom(mappe)) throw fejl(`Mappen ${mappe} findes og indeholder allerede andre filer. Jeg lægger ikke Progmaskinen oven i dem. Vælg en anden mappe med --mappe.`);
271
+ }
272
+
273
+ // 3. Hent og verificér.
274
+ const prerelease = valg.prerelease || (!!env.PROGMASKINEN_PRERELEASE && env.PROGMASKINEN_PRERELEASE !== '0');
275
+ const { data, version } = await hentUdgivelse(env.PROGMASKINEN_UDGIVELSER_URL ?? UDGIVELSER, prerelease, skriv);
276
+
277
+ // 4. Pak ud ved siden af målet (samme drev, så den kan flyttes på plads), og flyt den hele mappe på én gang.
278
+ const foraeldre = dirname(mappe);
279
+ mkdirSync(foraeldre, { recursive: true });
280
+ tmp = mkdtempSync(join(foraeldre, TMP_PRAEFIKS));
281
+ const zip = join(tmp, 'udgivelse.zip');
282
+ writeFileSync(zip, data);
283
+ const udpakket = join(tmp, 'udpakket');
284
+ mkdirSync(udpakket);
285
+ pakUd(zip, udpakket);
286
+ const rod = findOgVerificer(udpakket);
287
+ if (existsSync(mappe)) rmdirSync(mappe); // kun en tom mappe kan fjernes her; indhold giver en fejl og ændrer intet
288
+ try {
289
+ renameSync(rod, mappe);
290
+ } catch (f) {
291
+ if (existsSync(mappe)) throw fejl(`Mappen ${mappe} blev oprettet af et andet program lige nu. Intet er overskrevet; prøv igen.`);
292
+ cpSync(rod, mappe, { recursive: true, force: false, errorOnExist: true });
293
+ }
294
+
295
+ // 5. Start onboarding.
296
+ const url = await startOnboarding(mappe, env);
297
+ skriv('');
298
+ skriv(`Progmaskinen ${version} ligger nu i ${mappe}`);
299
+ skriv(url ? `Næste skridt står i din browser (${url}). Åbner den ikke af sig selv, så gå til adressen.` : 'Næste skridt står i din browser. Åbner den ikke af sig selv, så åbn mappen i VS Code og skriv /næste i Claude Code.');
300
+ return 0;
301
+ } catch (f) {
302
+ if (f instanceof InstallationsFejl) {
303
+ console.error(f.message);
304
+ return 1;
305
+ }
306
+ console.error(`Installationen fejlede uventet (${f.code ?? f.message}). Intet er ødelagt; prøv igen.`);
307
+ return 1;
308
+ } finally {
309
+ // Kun vores egen midlertidige mappe slettes (navnet er kontrolleret).
310
+ if (tmp && basename(tmp).startsWith(TMP_PRAEFIKS) && dirname(tmp) !== tmp && !tmp.endsWith(sep)) rmSync(tmp, { recursive: true, force: true });
311
+ }
312
+ }
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "progmaskinen",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0-beta.2",
4
+ "description": "Henter og installerer Progmaskinen, træning til programmeringseksamen på HA(it.) 1. semester.",
5
+ "type": "module",
6
+ "bin": {
7
+ "progmaskinen": "bin/progmaskinen.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "lib",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20.11"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/jakob-busk/progmaskinen-hent.git"
21
+ },
22
+ "homepage": "https://github.com/jakob-busk/progmaskinen-hent#readme",
23
+ "license": "SEE LICENSE IN LICENSE",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ }
27
+ }