edupage-cli 0.1.0

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 (49) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +250 -0
  3. data/exe/edupage +7 -0
  4. data/lib/edupage/account.rb +121 -0
  5. data/lib/edupage/cache.rb +101 -0
  6. data/lib/edupage/cli/formatter.rb +56 -0
  7. data/lib/edupage/cli/table.rb +136 -0
  8. data/lib/edupage/cli.rb +543 -0
  9. data/lib/edupage/client.rb +152 -0
  10. data/lib/edupage/config.rb +96 -0
  11. data/lib/edupage/credentials/env.rb +27 -0
  12. data/lib/edupage/credentials/keychain.rb +120 -0
  13. data/lib/edupage/credentials.rb +118 -0
  14. data/lib/edupage/errors.rb +74 -0
  15. data/lib/edupage/model.rb +131 -0
  16. data/lib/edupage/models/assignment.rb +83 -0
  17. data/lib/edupage/models/classroom.rb +13 -0
  18. data/lib/edupage/models/day.rb +51 -0
  19. data/lib/edupage/models/grade.rb +100 -0
  20. data/lib/edupage/models/lesson.rb +58 -0
  21. data/lib/edupage/models/parent.rb +8 -0
  22. data/lib/edupage/models/period.rb +13 -0
  23. data/lib/edupage/models/person.rb +25 -0
  24. data/lib/edupage/models/school_class.rb +16 -0
  25. data/lib/edupage/models/student.rb +45 -0
  26. data/lib/edupage/models/subject.rb +9 -0
  27. data/lib/edupage/models/teacher.rb +25 -0
  28. data/lib/edupage/models/term.rb +36 -0
  29. data/lib/edupage/models/timeline_item.rb +87 -0
  30. data/lib/edupage/parsers/base.rb +150 -0
  31. data/lib/edupage/parsers/gcall.rb +53 -0
  32. data/lib/edupage/parsers/timeline.rb +46 -0
  33. data/lib/edupage/parsers/userhome.rb +84 -0
  34. data/lib/edupage/parsers/znamky.rb +83 -0
  35. data/lib/edupage/registry/resources.rb +175 -0
  36. data/lib/edupage/registry.rb +286 -0
  37. data/lib/edupage/relation.rb +185 -0
  38. data/lib/edupage/school.rb +358 -0
  39. data/lib/edupage/serializer.rb +53 -0
  40. data/lib/edupage/server/api.rb +113 -0
  41. data/lib/edupage/server/auth.rb +45 -0
  42. data/lib/edupage/server/mcp.rb +107 -0
  43. data/lib/edupage/server.rb +57 -0
  44. data/lib/edupage/session.rb +166 -0
  45. data/lib/edupage/session_store.rb +131 -0
  46. data/lib/edupage/version.rb +3 -0
  47. data/lib/edupage/year.rb +77 -0
  48. data/lib/edupage.rb +64 -0
  49. metadata +201 -0
@@ -0,0 +1,543 @@
1
+ require "io/console"
2
+ require "json"
3
+ require "shellwords"
4
+ require "thor"
5
+
6
+ module Edupage
7
+ # Command line interface.
8
+ #
9
+ # Resource commands are not written by hand: they are generated from Registry, so the
10
+ # CLI gains a command the moment a resource is declared and its options always match
11
+ # the REST query parameters and the MCP input schema.
12
+ class CLI < Thor
13
+ TYPE_MAP = { integer: :numeric, boolean: :boolean }.freeze
14
+
15
+ def self.exit_on_failure? = true
16
+
17
+ class_option :username, type: :string, desc: "Edupage login (default: config default_username)"
18
+ class_option :school, type: :string, desc: "School origin (default: config default_school)"
19
+ class_option :student, type: :string, desc: "Student name or id"
20
+ class_option :year, type: :string, desc: "School year, e.g. 2025"
21
+ class_option :no_cache, type: :boolean, desc: "Ignore the cache and refetch"
22
+ class_option :json, type: :boolean, desc: "Output JSON"
23
+ class_option :yaml, type: :boolean, desc: "Output YAML"
24
+ class_option :verbose, type: :boolean, desc: "Log requests to stderr"
25
+
26
+ # --- generated resource commands ---------------------------------------------------
27
+
28
+ Registry.each do |resource|
29
+ desc resource.name.to_s, resource.summary
30
+
31
+ # Scope parameters are class options already; only the resource's own filters
32
+ # become per-command options.
33
+ resource.own_params.each do |param|
34
+ method_option param.name,
35
+ type: TYPE_MAP.fetch(param.type, :string),
36
+ desc: param.desc,
37
+ enum: param.enum? ? param.values : nil,
38
+ required: param.required
39
+ end
40
+
41
+ define_method(resource.name) do
42
+ run_resource(resource)
43
+ end
44
+ end
45
+
46
+ # --- credentials -------------------------------------------------------------------
47
+
48
+ desc "login", "Verify credentials and store the password in the macOS keychain"
49
+ method_option :username, type: :string, desc: "Edupage login (email)"
50
+ method_option :stdin, type: :boolean, desc: "Read the password from stdin instead of prompting"
51
+ def login
52
+ keychain = require_keychain!
53
+ username = options[:username] || Edupage.config.default_username ||
54
+ ask("Username (email):")
55
+ school = options[:school] || Edupage.config.default_school || ask("School (e.g. zsdemo):")
56
+ password = options[:stdin] ? $stdin.gets.to_s.chomp : ask_password
57
+
58
+ raise Error, "No password given" if password.empty?
59
+
60
+ # Verified before it is stored, so a typo never ends up in the keychain.
61
+ users = Client.mauth(school: school, username: username, password: password)
62
+ keychain.store(username: username, password: password)
63
+
64
+ say "Stored password for #{username}."
65
+ say "Schools: #{users.map { |u| u[:origin] }.join(", ")}"
66
+ remember_defaults(username, school)
67
+ end
68
+
69
+ desc "logout", "Remove the stored password from the keychain"
70
+ method_option :username, type: :string
71
+ def logout
72
+ keychain = require_keychain!
73
+ username = options[:username] || Edupage.config.default_username or
74
+ raise MissingCredentialsError, "No username; pass --username."
75
+
76
+ say(keychain.delete(username: username) ? "Removed password for #{username}." : "Nothing stored for #{username}.")
77
+ say "Session cache is separate; use `edupage session logout` to drop it.", :yellow
78
+ end
79
+
80
+ desc "auth", "Show where credentials are coming from"
81
+ def auth
82
+ credentials = Credentials.new(username: options[:username], school: options[:school])
83
+ username = safe { credentials.username }
84
+
85
+ say Table.plain([
86
+ auth_row("username", username, credentials.username_source),
87
+ auth_row("school", safe { credentials.school }, credentials.school_source),
88
+ auth_row("password", credentials.password_source ? "(set)" : "(missing)",
89
+ credentials.password_source),
90
+ auth_row("keychain", keychain_status(username), nil)
91
+ ])
92
+
93
+ return unless credentials.password_shadowed?
94
+
95
+ say "EDUPAGE_PASSWORD is set and overrides the keychain entry.", :yellow
96
+ end
97
+
98
+ # --- session and cache ---------------------------------------------------------------
99
+
100
+ desc "session SUBCOMMAND", "status | refresh | logout"
101
+ def session(subcommand = "status")
102
+ store = SessionStore.new
103
+ username = Credentials.new(username: options[:username]).username
104
+
105
+ case subcommand
106
+ when "status"
107
+ entries = store.all(username)
108
+ return say("No stored sessions for #{username}.") if entries.empty?
109
+
110
+ rows = entries.map { |origin, entry| [origin, entry[:userid], "saved #{entry[:saved_at]}"] }
111
+ say Table.plain(rows)
112
+ when "refresh"
113
+ store.delete(username)
114
+ account = Edupage.account(username: username, school: options[:school])
115
+ say "Logged in again: #{account.schools.map(&:origin).join(", ")}"
116
+ when "logout"
117
+ store.delete(username)
118
+ say "Dropped stored sessions for #{username}. The keychain password is untouched."
119
+ else
120
+ raise Error, "Unknown session subcommand #{subcommand.inspect}"
121
+ end
122
+ end
123
+
124
+ desc "cache SUBCOMMAND", "info | clear"
125
+ def cache(subcommand = "info")
126
+ store = Cache.new
127
+
128
+ case subcommand
129
+ when "info"
130
+ entries = store.entries
131
+ say Table.plain([
132
+ ["cache dir", ":", store.root],
133
+ ["entries", ":", entries.size.to_s],
134
+ ["size", ":", "#{entries.sum { |f| File.size(f) } / 1024} KiB"]
135
+ ])
136
+ when "clear"
137
+ store.clear
138
+ say "Cache cleared."
139
+ else
140
+ raise Error, "Unknown cache subcommand #{subcommand.inspect}"
141
+ end
142
+ end
143
+
144
+ desc "config SUBCOMMAND [KEY] [VALUE]", "path | get | set"
145
+ def config(subcommand = "path", key = nil, value = nil)
146
+ case subcommand
147
+ when "path" then say Edupage.config.path
148
+ when "get" then say(key ? Edupage.config[key].inspect : Edupage.config.to_h.inspect)
149
+ when "set"
150
+ raise Error, "Usage: edupage config set KEY VALUE" if key.nil? || value.nil?
151
+
152
+ Edupage.config[key] = value
153
+ Edupage.config.save
154
+ say "#{key} = #{value}"
155
+ else
156
+ raise Error, "Unknown config subcommand #{subcommand.inspect}"
157
+ end
158
+ end
159
+
160
+ # --- servers --------------------------------------------------------------------------
161
+
162
+ desc "server", "Serve the REST API and the MCP endpoint"
163
+ method_option :host, type: :string, desc: "Bind address (default 127.0.0.1)"
164
+ method_option :port, type: :numeric, desc: "Port (default 4567)"
165
+ method_option :token, type: :string, desc: "Bearer token (default: generated, stored in config)"
166
+ def server
167
+ Server.start(host: options[:host], port: options[:port], token: options[:token],
168
+ account_options: account_options)
169
+ end
170
+
171
+ desc "mcp", "Serve MCP over stdio, for editors and desktop clients"
172
+ def mcp
173
+ Server::MCP.new(account_options: account_options).run_stdio
174
+ end
175
+
176
+ MCP_TARGETS = %w[claude-code claude-desktop all].freeze
177
+
178
+ CLAUDE_DESKTOP_CONFIG =
179
+ "~/Library/Application Support/Claude/claude_desktop_config.json".freeze
180
+
181
+ desc "mcp-add TARGET", "Register this server with #{MCP_TARGETS.join(", ")}"
182
+ method_option :http, type: :boolean,
183
+ desc: "Register the HTTP endpoint of a running `edupage server` instead of stdio"
184
+ method_option :name, type: :string, desc: "Key under mcpServers (default: edupage)"
185
+ method_option :host, type: :string, desc: "Host for --http"
186
+ method_option :port, type: :numeric, desc: "Port for --http"
187
+ method_option :scope, type: :string, enum: %w[local user project],
188
+ desc: "Claude Code scope (default: local)"
189
+ method_option :dry_run, type: :boolean, aliases: "-n",
190
+ desc: "Show what would be done without registering anything"
191
+ map "mcp-add" => :mcp_add
192
+ def mcp_add(target = nil)
193
+ unless MCP_TARGETS.include?(target)
194
+ raise Thor::Error, "Usage: edupage mcp-add TARGET, where TARGET is #{MCP_TARGETS.join(", ")}"
195
+ end
196
+
197
+ name = options[:name] || "edupage"
198
+ entry = options[:http] ? http_entry : stdio_entry
199
+ targets = target == "all" ? MCP_TARGETS - ["all"] : [target]
200
+
201
+ targets.each do |each|
202
+ case each
203
+ when "claude-code" then add_to_claude_code(name, entry)
204
+ when "claude-desktop" then add_to_claude_desktop(name, entry)
205
+ end
206
+ end
207
+ end
208
+
209
+ desc "mcp-config", "Print an mcpServers entry for Claude Desktop or Claude Code"
210
+ method_option :http, type: :boolean,
211
+ desc: "Connect to a running `edupage server` over HTTP instead of stdio"
212
+ method_option :name, type: :string, desc: "Key under mcpServers (default: edupage)"
213
+ method_option :host, type: :string, desc: "Host for --http"
214
+ method_option :port, type: :numeric, desc: "Port for --http"
215
+ # Thor looks commands up by method name, so the hyphenated form needs mapping.
216
+ map "mcp-config" => :mcp_config
217
+ def mcp_config
218
+ name = options[:name] || "edupage"
219
+ entry = options[:http] ? http_entry : stdio_entry
220
+
221
+ # Nothing but the JSON, so `edupage mcp-config > entry.json` and piping into jq
222
+ # both work. Use `mcp-add --dry-run` to see what registering would do.
223
+ $stdout.puts(::JSON.pretty_generate("mcpServers" => { name => entry }))
224
+ end
225
+
226
+ desc "version", "Print the version"
227
+ def version = say(VERSION)
228
+
229
+ private
230
+
231
+ def run_resource(resource)
232
+ Edupage.logger.level = ::Logger::DEBUG if options[:verbose]
233
+
234
+ context = Registry::Context.new(
235
+ account: current_account, school: options[:school],
236
+ student: options[:student], year: options[:year]
237
+ )
238
+ result = resource.call(context, options)
239
+
240
+ # Only the table view gets the header: --json and --yaml stay byte-identical to
241
+ # what the REST API and the MCP tools return.
242
+ say(chain_header(resource, context)) if table_output? && !resource.scope_chain.empty?
243
+ formatter.render(result, resource: resource)
244
+ say(year_hint(resource, context), :yellow) if empty_year_result?(resource, context, result)
245
+ rescue AmbiguousScopeError => e
246
+ raise Thor::Error, choice_message(e)
247
+ rescue Error => e
248
+ raise Thor::Error, e.message
249
+ end
250
+
251
+ # Shows which school, student and year the answer came from, so a list is never
252
+ # ambiguous about whose it is.
253
+ def chain_header(resource, context)
254
+ rows = resource.scope_chain.map do |level|
255
+ case level
256
+ when :school then ["school", ":", "#{context.school.origin} (#{context.school.name})"]
257
+ when :student then ["student", ":", context.student.to_s]
258
+ when :year then ["year", ":", "#{context.year}#{context.year_defaulted? ? " [default]" : ""}"]
259
+ end
260
+ end
261
+
262
+ "#{Table.plain(rows)}\n"
263
+ end
264
+
265
+ def choice_message(error)
266
+ rows = error.candidates.map { |c| ["--#{error.level}", c[:id].to_s, c[:label].to_s] }
267
+
268
+ "No #{error.level} selected. Pick one:\n#{Table.plain(rows, indent: 2)}"
269
+ end
270
+
271
+ # In September the current year is empty and last year's data is what was meant, so
272
+ # an empty defaulted year points at the years that do have something.
273
+ def year_hint(_resource, context)
274
+ elsewhere = context.student.years.reject(&:current?).select { |y| y.grade_count.positive? }
275
+ return "Nothing here for #{context.year}." if elsewhere.empty?
276
+
277
+ suggestions = elsewhere.first(3).map { |y| "--year #{y.id} (#{y.grade_count})" }
278
+ "Nothing here for #{context.year}. Try #{suggestions.join(", ")}."
279
+ end
280
+
281
+ def empty_year_result?(resource, context, result)
282
+ table_output? && resource.scope == :year && context.year_defaulted? &&
283
+ result.respond_to?(:empty?) && result.empty?
284
+ end
285
+
286
+ def table_output? = !options[:json] && !options[:yaml]
287
+
288
+ # Not named `account`: that is a generated command, and a private method of the
289
+ # same name would silently replace it.
290
+ def current_account
291
+ @current_account ||= Edupage.account(
292
+ username: options[:username], school: options[:school],
293
+ cache: Cache.new(enabled: !options[:no_cache])
294
+ )
295
+ end
296
+
297
+ # Defaults the long-running servers inherit from the command line, so
298
+ # `edupage server --username x --school y` needs no config file.
299
+ def account_options
300
+ {
301
+ username: options[:username],
302
+ school: options[:school],
303
+ student: options[:student],
304
+ year: options[:year]
305
+ }.compact
306
+ end
307
+
308
+ def formatter
309
+ Formatter.new(output: $stdout,
310
+ format: options[:json] ? :json : (options[:yaml] ? :yaml : :table))
311
+ end
312
+
313
+ def require_keychain!
314
+ unless Credentials::Keychain.available?
315
+ raise Thor::Error, "The macOS keychain is unavailable on #{RUBY_PLATFORM}; use EDUPAGE_PASSWORD."
316
+ end
317
+
318
+ Credentials::Keychain.new
319
+ end
320
+
321
+ def ask_password
322
+ $stderr.print "Password: "
323
+ password = $stdin.noecho(&:gets).to_s.chomp
324
+ $stderr.puts
325
+ password
326
+ end
327
+
328
+ def remember_defaults(username, school)
329
+ changed = false
330
+ if Edupage.config.default_username.nil?
331
+ Edupage.config["default_username"] = username
332
+ changed = true
333
+ end
334
+ if Edupage.config.default_school.nil?
335
+ Edupage.config["default_school"] = school
336
+ changed = true
337
+ end
338
+ return unless changed
339
+
340
+ Edupage.config.save
341
+ say "Saved defaults to #{Edupage.config.path}."
342
+ end
343
+
344
+ # --- mcp-config helpers -------------------------------------------------------------
345
+
346
+ # stdio needs no token: the client owns the process, so there is nothing to
347
+ # authenticate. It is the better default for a local tool.
348
+ def stdio_entry
349
+ entry = { "command" => executable_path, "args" => ["mcp"] }
350
+
351
+ if bundler_gemfile
352
+ # Running from a checkout rather than an installed gem. Going through bundler
353
+ # with an absolute BUNDLE_GEMFILE is what makes this work from any directory -
354
+ # MCP clients start servers with a working directory of their own choosing.
355
+ entry["command"] = bundler_path
356
+ entry["args"] = ["exec", executable_path, "mcp"]
357
+ entry["env"] = { "BUNDLE_GEMFILE" => bundler_gemfile }
358
+ end
359
+
360
+ # The account is authentication, not a level of the chain, so pinning it is safe;
361
+ # school and student stay out on purpose, so the model has to choose them.
362
+ username = safe { Credentials.new(username: options[:username]).username }
363
+ entry["args"] += ["--username", username] if username
364
+ entry
365
+ end
366
+
367
+ def http_entry
368
+ host = options[:host] || Edupage.config.server["host"] || Server::DEFAULT_HOST
369
+ port = options[:port] || Edupage.config.server["port"] || Server::DEFAULT_PORT
370
+
371
+ {
372
+ "type" => "http",
373
+ "url" => "http://#{host}:#{port}/mcp",
374
+ "headers" => { "Authorization" => "Bearer #{Edupage.config.server_token}" }
375
+ }
376
+ end
377
+
378
+ def executable_path
379
+ File.expand_path($PROGRAM_NAME)
380
+ end
381
+
382
+ def bundler_gemfile
383
+ gemfile = ENV["BUNDLE_GEMFILE"]
384
+ return File.expand_path(gemfile) if gemfile && File.exist?(gemfile)
385
+
386
+ nil
387
+ end
388
+
389
+ def bundler_path
390
+ which("bundle") || "bundle"
391
+ end
392
+
393
+ # Claude Code owns its own config, so registration goes through its CLI rather than
394
+ # editing the file behind its back.
395
+ def add_to_claude_code(name, entry)
396
+ command = claude_add_command(name, entry)
397
+
398
+ if options[:dry_run]
399
+ say "claude-code : would run"
400
+ say " #{command}"
401
+ return
402
+ end
403
+
404
+ raise Thor::Error, "`claude` is not on PATH; install Claude Code or use mcp-config" if which("claude").nil?
405
+
406
+ # Idempotent: `claude mcp add` refuses a name it already knows, so an existing
407
+ # entry is dropped first and re-added. Running this twice converges instead of
408
+ # failing the second time.
409
+ existed = claude_code_knows?(name)
410
+ system("claude", "mcp", "remove", name, *scope_arguments, out: File::NULL, err: File::NULL) if existed
411
+
412
+ raise Thor::Error, "`claude mcp add` failed" unless system(command, out: File::NULL)
413
+
414
+ say "claude-code : #{existed ? "updated" : "added"} #{name}#{scope_note}"
415
+ end
416
+
417
+ def claude_code_knows?(name)
418
+ system("claude", "mcp", "get", name, out: File::NULL, err: File::NULL)
419
+ end
420
+
421
+ def scope_arguments = options[:scope] ? ["-s", options[:scope]] : []
422
+ def scope_note = options[:scope] ? " (#{options[:scope]} scope)" : ""
423
+
424
+ # Claude Desktop has no CLI, so its JSON is merged by hand - preserving the other
425
+ # servers and the unrelated top-level keys it keeps in the same file.
426
+ def add_to_claude_desktop(name, entry)
427
+ path = File.expand_path(CLAUDE_DESKTOP_CONFIG)
428
+ config = read_json_file(path)
429
+ servers = config["mcpServers"] ||= {}
430
+
431
+ # Idempotent: an identical entry is left alone, a different one is brought into
432
+ # line, and a missing one is added.
433
+ current = servers[name]
434
+ action = if current.nil? then :added
435
+ elsif current == entry then :unchanged
436
+ else :updated
437
+ end
438
+
439
+ if options[:dry_run]
440
+ say "claude-desktop: would leave #{name.inspect} unchanged in #{path}" and return if action == :unchanged
441
+
442
+ say "claude-desktop: would #{action == :added ? "add" : "update"} #{name.inspect} in #{path}"
443
+ say ::JSON.pretty_generate(name => entry).gsub(/^/, " ")
444
+ return
445
+ end
446
+
447
+ return say "claude-desktop: #{name} already up to date" if action == :unchanged
448
+
449
+ servers[name] = entry
450
+ write_json_file(path, config)
451
+ say "claude-desktop: #{action} #{name} in #{path}"
452
+ say " restart Claude Desktop for it to pick this up", :yellow
453
+ end
454
+
455
+ def read_json_file(path)
456
+ return {} unless File.exist?(path)
457
+
458
+ # UTF-8 explicitly; other servers in this file may have non-ASCII values and the
459
+ # locale is not guaranteed to be set.
460
+ content = File.read(path, encoding: Encoding::UTF_8)
461
+ return {} if content.strip.empty?
462
+
463
+ ::JSON.parse(content)
464
+ rescue ::JSON::ParserError => e
465
+ raise Thor::Error, "#{path} is not valid JSON (#{e.message}); fix or move it first"
466
+ end
467
+
468
+ # Writes via a temporary file and keeps one backup: this is a config the user owns
469
+ # and may have other servers in.
470
+ def write_json_file(path, data)
471
+ FileUtils.mkdir_p(File.dirname(path))
472
+ FileUtils.cp(path, "#{path}.bak") if File.exist?(path)
473
+
474
+ temp = "#{path}.#{Process.pid}.tmp"
475
+ File.open(temp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
476
+ file.set_encoding(Encoding::UTF_8)
477
+ file.write(::JSON.pretty_generate(data))
478
+ file.write("\n")
479
+ end
480
+ File.rename(temp, path)
481
+ end
482
+
483
+ # Windows finds `bundle` as bundle.bat, so the PATHEXT extensions are tried too.
484
+ def which(command)
485
+ extensions = [""] + ENV.fetch("PATHEXT", "").split(File::PATH_SEPARATOR)
486
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
487
+ .product(extensions)
488
+ .map { |dir, ext| File.join(dir, command + ext) }
489
+ .find { |candidate| File.executable?(candidate) && !File.directory?(candidate) }
490
+ end
491
+
492
+ # Builds the equivalent `claude mcp add` invocation, shell-quoted so it can be
493
+ # pasted or piped straight into a shell.
494
+ #
495
+ # The two transports take different shapes: stdio passes the subprocess after `--`,
496
+ # while HTTP takes a URL and repeatable --header flags.
497
+ def claude_add_command(name, entry)
498
+ parts = ["claude", "mcp", "add"]
499
+ parts += ["-s", options[:scope]] if options[:scope]
500
+
501
+ if entry["type"] == "http"
502
+ parts += ["--transport", "http", name, entry["url"]]
503
+ entry.fetch("headers", {}).each { |key, value| parts += ["--header", "#{key}: #{value}"] }
504
+ else
505
+ # The name has to come before -e: `claude mcp add` takes --env variadically, so
506
+ # a name placed after it is swallowed as another KEY=value and rejected with
507
+ # "Invalid environment variable format".
508
+ parts << name
509
+ entry.fetch("env", {}).each { |key, value| parts += ["-e", "#{key}=#{value}"] }
510
+ parts += ["--", entry["command"], *entry["args"]]
511
+ end
512
+
513
+ parts.map { |part| shell_quote(part) }.join(" ")
514
+ end
515
+
516
+ # Shellwords.escape is correct but backslash-escapes every `=` and space, which
517
+ # makes the line hard to read. Only what actually needs quoting gets quoted.
518
+ SHELL_SAFE = %r{\A[A-Za-z0-9_@%+=:,./-]+\z}
519
+
520
+ def shell_quote(part)
521
+ return part if part.match?(SHELL_SAFE)
522
+
523
+ "'#{part.gsub("'", %q('\''))}'"
524
+ end
525
+
526
+ def keychain_status(username)
527
+ return "unavailable on #{RUBY_PLATFORM}" unless Credentials::Keychain.available?
528
+ return "available (service edupage-cli), but no username to look up" unless username
529
+
530
+ Credentials::Keychain.new.stored?(username: username) ? "stored for #{username}" : "nothing stored for #{username}"
531
+ end
532
+
533
+ def auth_row(label, value, source)
534
+ [label, ":", value || "(missing)", source ? "[#{source}]" : ""]
535
+ end
536
+
537
+ def safe
538
+ yield
539
+ rescue Error
540
+ nil
541
+ end
542
+ end
543
+ end
@@ -0,0 +1,152 @@
1
+ require "mechanize"
2
+ require "json"
3
+
4
+ module Edupage
5
+ # Raw HTTP against one Edupage origin.
6
+ #
7
+ # Redirects are deliberately NOT followed: an expired session answers a normal page
8
+ # request with `302 -> /login/...`, which is the cheapest reliable way to notice.
9
+ # Following it would instead yield a 40 KB login page that has to be sniffed for.
10
+ #
11
+ # Knows nothing about sessions or cursors; Session adds those on top.
12
+ class Client
13
+ USER_AGENT = "edupage-cli/#{VERSION} (+https://github.com/alhafoudh/edupage-cli)".freeze
14
+ MAUTH_PATH = "/login/mauth".freeze
15
+
16
+ # Mirrors the payload the official mobile client sends. Most fields are ignored by
17
+ # the server but omitting them changes the response shape.
18
+ MAUTH_PAYLOAD = {
19
+ "plgc" => "", "ajheslo" => "1", "hasujheslo" => "1", "ajportal" => "1",
20
+ "ajportallogin" => "1", "mobileLogin" => "1", "version" => "2020.0.18",
21
+ "device_name" => "", "device_id" => "", "device_key" => "", "os" => "",
22
+ "murl" => "", "edid" => ""
23
+ }.freeze
24
+
25
+ Response = Struct.new(:status, :body, :uri, keyword_init: true) do
26
+ def ok? = status.between?(200, 299)
27
+
28
+ # Both signals are verified against the live server: a dead session redirects
29
+ # page requests to /login/, and answers portalping with the literal "notlogged".
30
+ def expired?
31
+ return true if status == 302 && location_is_login?
32
+ return true if body.to_s.strip == "notlogged"
33
+
34
+ false
35
+ end
36
+
37
+ def location_is_login? = uri.to_s.include?("/login/")
38
+
39
+ def json = JSON.parse(body)
40
+ end
41
+
42
+ class << self
43
+ # Exchanges credentials for one session per school the account can reach.
44
+ #
45
+ # A single login can span several schools (a parent with children at two
46
+ # schools), and each entry carries its own esid - so this returns a list, not one
47
+ # session.
48
+ def mauth(school:, username:, password:)
49
+ # The login server is the school's own subdomain; the generic "login1" host
50
+ # rejects passwords for school-scoped accounts.
51
+ agent = build_agent
52
+ payload = MAUTH_PAYLOAD.merge(
53
+ "m" => username, "h" => password,
54
+ "edupage" => school.to_s, "fromEdupage" => school.to_s
55
+ )
56
+
57
+ page = agent.post("https://#{school}.edupage.org#{MAUTH_PATH}", payload)
58
+ data = JSON.parse(page.body)
59
+
60
+ users = data["users"] || []
61
+ if users.empty?
62
+ raise LoginError, data["needEdupage"] ? "Incorrect username" : "Incorrect password"
63
+ end
64
+
65
+ users.map do |user|
66
+ {
67
+ userid: user["userid"],
68
+ first_name: user["first_name"] || user["firstname"],
69
+ last_name: user["last_name"] || user["lastname"],
70
+ origin: user["edupage"],
71
+ session_id: user["esid"],
72
+ role: user["typ"],
73
+ needs_2fa: user["need2fa"].to_s == "1"
74
+ }
75
+ end
76
+ rescue JSON::ParserError => e
77
+ raise LoginError, "Login endpoint returned something that is not JSON: #{e.message}"
78
+ end
79
+
80
+ def build_agent
81
+ Mechanize.new do |a|
82
+ a.user_agent = USER_AGENT
83
+ a.redirect_ok = false
84
+ a.follow_meta_refresh = false
85
+ # Pages are 200-400 KB of HTML but every parser here works on the embedded
86
+ # <script> JSON via regex, so building a DOM would be pure waste.
87
+ a.pluggable_parser.default = Mechanize::File
88
+ a.pluggable_parser.html = Mechanize::File
89
+ a.open_timeout = 15
90
+ a.read_timeout = 60
91
+ end
92
+ end
93
+ end
94
+
95
+ attr_reader :origin
96
+ attr_accessor :session_id
97
+
98
+ def initialize(origin:, session_id: nil)
99
+ @origin = origin
100
+ @session_id = session_id
101
+ @agent = self.class.build_agent
102
+ end
103
+
104
+ def base_url = "https://#{origin}.edupage.org"
105
+
106
+ def get(path)
107
+ request(:get, path)
108
+ end
109
+
110
+ def post(path, data = {})
111
+ request(:post, path, data)
112
+ end
113
+
114
+ private
115
+
116
+ def request(method, path, data = nil)
117
+ url = path.start_with?("http") ? path : "#{base_url}#{path}"
118
+
119
+ page =
120
+ if method == :post
121
+ @agent.post(url, data || {}, headers)
122
+ else
123
+ @agent.get(url, [], nil, headers)
124
+ end
125
+
126
+ Response.new(status: page.code.to_i, body: page.body, uri: response_location(page, url))
127
+ rescue Mechanize::ResponseCodeError => e
128
+ code = e.response_code.to_i
129
+ # 3xx arrives here when Mechanize declines to follow it.
130
+ return Response.new(status: code, body: e.page&.body.to_s, uri: response_location(e.page, url)) if code < 400
131
+
132
+ raise RequestError, "#{method.to_s.upcase} #{path} failed with HTTP #{code}"
133
+ rescue Mechanize::Error, Net::OpenTimeout, Net::ReadTimeout, SocketError, Errno::ECONNRESET => e
134
+ raise RequestError, "#{method.to_s.upcase} #{path} failed: #{e.class}: #{e.message}"
135
+ end
136
+
137
+ def response_location(page, fallback)
138
+ location = page&.response&.[]("location")
139
+ location || fallback
140
+ end
141
+
142
+ def headers
143
+ h = {
144
+ "Accept" => "application/json, text/javascript, */*; q=0.01",
145
+ "X-Requested-With" => "XMLHttpRequest",
146
+ "Referer" => "#{base_url}/"
147
+ }
148
+ h["Cookie"] = "PHPSESSID=#{session_id}" if session_id
149
+ h
150
+ end
151
+ end
152
+ end