@msout/microsoft-onenote-exporter 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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,51 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-10-01
10
+
11
+ The first release. It adds a CLI over three existing packages and changes none of
12
+ their behaviour.
13
+
14
+ ### Added
15
+
16
+ - **`microsoft-onenote-exporter <login|check|logout|list|export>`.** One binary over
17
+ `@msout/microsoft-webauth`, `@msout/microsoft-onenote-list-notebooks` and
18
+ `@msout/microsoft-onenote-export-notebook`, all three of which remain
19
+ separately installable and separately tested. A typical first run is `login`,
20
+ then `list`, then `export --notebook "Work"`.
21
+ - **A single Playwright, and therefore a single Chromium.** The three step
22
+ packages each declared `playwright: ^1.58.1` behind their own lockfile, so npm
23
+ resolved them independently — 1.61.0 in one, 1.61.1 in another — and each
24
+ wanted a different Chromium revision. A machine with all three checkouts out
25
+ held four revisions and 3.2 GB of browser. They are now pinned to exact versions
26
+ with an `overrides` block forcing one `playwright` and `playwright-core` across
27
+ the tree, and `test/wiring.test.js` fails if that ever stops being true.
28
+ - **One log per run.** Each package resolved its own log directory at load time,
29
+ so a pipeline produced three `app.log` files in three directories. The
30
+ environment variable those packages read is set before any of them is loaded,
31
+ so all four loggers — this CLI's and the three steps' — write one file.
32
+ - **One Docker image** for all five commands, with one Chromium. It replaces
33
+ three images that each carried their own browser; the list image also installed
34
+ a distribution `chromium` from apt that nothing used. `start-container.sh`
35
+ replaces the positional `<session-guid> <notebook-name>` interface with
36
+ subcommands, waits for the run, and explains exit code 3 as a partial export
37
+ rather than a failure.
38
+ - **Exit codes that mean something.** 0 success, 1 failure, 2 bad arguments, and
39
+ 3 for an export that finished while missing pages, sections or groups. The
40
+ third is passed through from the export package's own `exitCodeForStats`, which
41
+ is asked rather than reimplemented, so the two CLIs cannot disagree.
42
+
43
+ ### Notes
44
+
45
+ - `login` cannot run headless without `--email` and `--password`, and the
46
+ container refuses `login` outright rather than opening a browser nobody can see.
47
+ Interactive sign-in happens on the host.
48
+ - `all`, which chains login → list → export in one invocation, is deliberately
49
+ not in this release. None of the three packages accepts an injected browser, so
50
+ it would still be three Chromium launches behind one command — and the change
51
+ needed to make it one launch reaches into the most delicate file in the set.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ms-OneNote-Exporter
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE.md ADDED
@@ -0,0 +1,38 @@
1
+ # Notices
2
+
3
+ This project is distributed under the MIT licence. It is an umbrella: it contains
4
+ no copy of the work below, and depends on it at runtime.
5
+
6
+ ## Bundled work
7
+
8
+ None. The three packages in the table below are installed from npm as ordinary
9
+ dependencies. None of their source is vendored into this repository or into the
10
+ Docker image built from it.
11
+
12
+ ## Dependencies
13
+
14
+ | Package | Version | Licence | Role |
15
+ |---|---|---|---|
16
+ | `@msout/microsoft-webauth` | 0.1.8 | MIT | `login`, `check`, `logout` |
17
+ | `@msout/microsoft-onenote-list-notebooks` | 0.0.6 | MIT | `list` |
18
+ | `@msout/microsoft-onenote-export-notebook` | 0.3.7 | MIT | `export` |
19
+
20
+ All three are by the same authors and under the same MIT licence as this project.
21
+ They are developed in sibling repositories:
22
+
23
+ - <https://github.com/Ms-OneNote-Exporter/microsoft-webauth>
24
+ - <https://github.com/Ms-OneNote-Exporter/microsoft-onenote-list-notebooks>
25
+ - <https://github.com/Ms-OneNote-Exporter/microsoft-onenote-export-notebook>
26
+
27
+ One file *is* duplicated on purpose: `logPaths.js` exists in all three packages
28
+ with only the package name changed. They are separately published and cannot share
29
+ source, and keeping the copies byte-identical is what stops the three from
30
+ disagreeing about where an installed copy writes its logs. It says so at the top
31
+ of each file, and it should collapse to one module if the packages are ever
32
+ merged.
33
+
34
+ ## Trademarks
35
+
36
+ Microsoft, OneNote, Outlook and Microsoft 365 are trademarks of Microsoft
37
+ Corporation. This project is not affiliated with or endorsed by Microsoft. It
38
+ automates a browser session against services the user already has an account for.
package/README.md ADDED
@@ -0,0 +1,199 @@
1
+ # microsoft-onenote-exporter
2
+
3
+ One command for the whole Microsoft OneNote pipeline: sign in, list the notebooks
4
+ on the account, export one to Obsidian-flavoured Markdown.
5
+
6
+ This is an umbrella over three packages that remain separately installable and
7
+ separately tested:
8
+
9
+ | Step | Package |
10
+ |---|---|
11
+ | `login`, `check`, `logout` | [`@msout/microsoft-webauth`](https://github.com/Ms-OneNote-Exporter/microsoft-webauth) |
12
+ | `list` | [`@msout/microsoft-onenote-list-notebooks`](https://github.com/Ms-OneNote-Exporter/microsoft-onenote-list-notebooks) |
13
+ | `export` | [`@msout/microsoft-onenote-export-notebook`](https://github.com/Ms-OneNote-Exporter/microsoft-onenote-export-notebook) |
14
+
15
+ Each one works on its own, and you can still install just the one you need. What
16
+ this adds is a single binary that can do all three, one Playwright install, one
17
+ Docker image, and one log per run.
18
+
19
+ ## Why it exists
20
+
21
+ The three packages each declared `playwright: ^1.58.1` behind their own
22
+ lockfile, so npm resolved them independently — 1.61.0 in one, 1.61.1 in another.
23
+ Each resolved version wants a different Chromium revision, so a machine with all
24
+ three checkouts out downloads several browser builds into the shared
25
+ `~/Library/Caches/ms-playwright` cache. On the machine this was built on, that
26
+ cache held four Chromium revisions and weighed 3.2 GB.
27
+
28
+ It also let a single run produce three `app.log` files in three directories,
29
+ because each package resolved its own log path at load time.
30
+
31
+ Both problems are fixed at the root rather than papered over:
32
+
33
+ - the three dependencies are pinned to **exact** versions, and `overrides` forces
34
+ a single `playwright` / `playwright-core` across the whole tree. A caret range
35
+ always takes the newest match, so it is the wrong tool here;
36
+ - `ONENOTE_EXPORT_LOG_DIR` is set before any step package is loaded, so all four
37
+ loggers write one `app.log`.
38
+
39
+ There is a test for each: `test/wiring.test.js` fails if more than one Playwright
40
+ is installed, or if the pins drift back to a range.
41
+
42
+ ## Install
43
+
44
+ ```sh
45
+ npm install -g @msout/microsoft-onenote-exporter
46
+ npx playwright install chromium
47
+ ```
48
+
49
+ The Chromium download is separate from `npm install` and is required — without it
50
+ every command that opens a browser fails.
51
+
52
+ Two command names are installed, pointing at the same binary:
53
+
54
+ ```sh
55
+ microsoft-onenote-exporter login # matches the package and repository name
56
+ ms-onenote-exporter login # shorter, for typing
57
+ ```
58
+
59
+ Everything below uses the long one.
60
+
61
+ ## Use
62
+
63
+ ```sh
64
+ microsoft-onenote-exporter login # opens a browser; sign in there
65
+ microsoft-onenote-exporter list # what is on the account
66
+ microsoft-onenote-exporter export --notebook "Work" # export one
67
+ ```
68
+
69
+ That is the whole pipeline. The session written by `login` is picked up by
70
+ `list` and `export` automatically.
71
+
72
+ ### Commands
73
+
74
+ | Command | What it does |
75
+ |---|---|
76
+ | `login` | Signs in and saves the session. Without `--email`/`--password` the browser opens and you sign in yourself — an interactive login cannot run headless. |
77
+ | `check` | Reports whether the saved session is still valid. A dead session is deleted, so the next command needs a fresh `login`. |
78
+ | `logout` | Deletes the saved session and its metadata. |
79
+ | `list` | Lists the notebooks on the account. An empty result is a success, not an error. |
80
+ | `export` | Exports one notebook to Markdown. |
81
+
82
+ ### Options
83
+
84
+ Every command accepts `--auth-file`, `--notheadless`, `--dodump`, `--verbose` and
85
+ `--quiet`.
86
+
87
+ | Command | Additional options |
88
+ |---|---|
89
+ | `login` | `--email`, `--password`, `--against onenote\|outlook`, `--screenshot` |
90
+ | `check` | `--against onenote\|outlook` |
91
+ | `export` | `--notebook <name>`, `--notebook-link <url>`, `--output-dir <path>`, `--nopassasked`, `--non-interactive` |
92
+
93
+ `--screenshot` only means something together with `--dodump`, so asking for one
94
+ turns the other on and says so.
95
+
96
+ ### Unattended use
97
+
98
+ `--non-interactive` is for containers and CI. It requires `--notebook` or
99
+ `--notebook-link`, and implies `--nopassasked`, so the run cannot stop at a prompt
100
+ nobody is there to answer:
101
+
102
+ ```sh
103
+ microsoft-onenote-exporter export --notebook "Work" --non-interactive --output-dir ./out
104
+ ```
105
+
106
+ ### Exit codes
107
+
108
+ | Code | Meaning |
109
+ |---|---|
110
+ | 0 | Success. |
111
+ | 1 | The command failed — unusable auth file, dead run. |
112
+ | 2 | The arguments were wrong. |
113
+ | 3 | The export finished, but pages, sections or groups are missing. |
114
+
115
+ Three is deliberately not an error: the notes that were written are complete and
116
+ name any asset they could not download, so the output is worth keeping and
117
+ re-running is worth doing. It comes from
118
+ `@msout/microsoft-onenote-export-notebook`'s own rule, which this CLI asks for
119
+ rather than reimplementing.
120
+
121
+ ## Logs
122
+
123
+ Every step of a run — this CLI's messages and all three packages' — goes to one
124
+ `logs/app.log` under the working directory. Override it with
125
+ `ONENOTE_EXPORT_LOG_DIR`, or use `--dodump` to also write the HTML of each page.
126
+
127
+ The directory and the log file are created owner-only. Dumps hold the
128
+ authenticated DOM of a real account: cookies, tenant hostnames, note titles.
129
+
130
+ ## Docker
131
+
132
+ ```sh
133
+ docker build -t microsoft-onenote-exporter .
134
+ ```
135
+
136
+ One image, one Chromium, serving all five commands. The auth file is read from
137
+ `/data/output/auth.json` unless you pass `--auth-file`.
138
+
139
+ ```sh
140
+ docker run --rm --init --shm-size=1g \
141
+ -v "$PWD/out:/data/output" \
142
+ microsoft-onenote-exporter export --notebook "Work" --non-interactive
143
+ ```
144
+
145
+ Or use the wrapper, which waits for the run and translates the exit code:
146
+
147
+ ```sh
148
+ ./start-container.sh --notebook "Work" --output-dir ./out
149
+ ```
150
+
151
+ `--shm-size=1g` is required: Chromium crashes on memory-heavy pages with Docker's
152
+ default 64 MB of shared memory. `--init` reaps Chromium's child processes.
153
+
154
+ `login` is refused inside the container — without credentials the browser has to
155
+ be shown, and there is nobody there to show it to. Log in on the host first and
156
+ mount the resulting file.
157
+
158
+ ## Development
159
+
160
+ The three step packages are pinned to versions that must exist on npm. To work
161
+ against local checkouts before they are published:
162
+
163
+ ```sh
164
+ npm run use:local # pack the sibling repos and install those
165
+ npm test
166
+ npm run use:published # go back to the registry copies
167
+ ```
168
+
169
+ `use:local` expects the three repositories one directory up, and leaves
170
+ `package.json` pinning published versions — only `node_modules` differs, so a
171
+ `file:` spec can never be committed.
172
+
173
+ ```sh
174
+ npm test # no browser and no account needed
175
+ npm run lint
176
+ ```
177
+
178
+ ### Release order
179
+
180
+ There is an ordering constraint, and it is the one thing that cannot be worked
181
+ around: **this package cannot have a committed `package-lock.json` until the
182
+ three step packages are on npm.** Their pinned versions do not resolve before
183
+ then, and `use:local` produces a lockfile full of `file:.local-tarballs/…`
184
+ entries that works only on the machine that made it — `.local-tarballs/` is
185
+ gitignored, so CI and the Docker image would both fail to install from it.
186
+
187
+ So the first release goes:
188
+
189
+ 1. Publish the three step packages from their own repositories.
190
+ 2. Here: `rm -rf node_modules .local-tarballs && npm install`.
191
+ 3. Commit the generated `package-lock.json`. After this, `npm ci` works and CI
192
+ and the Dockerfile function.
193
+
194
+ `test/lockfile.test.js` fails with these instructions until step 3 happens, so the
195
+ gap is visible rather than discovered during a build.
196
+
197
+ ## Licence
198
+
199
+ MIT. See [LICENSE](LICENSE) and [NOTICE.md](NOTICE.md).
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@msout/microsoft-onenote-exporter",
3
+ "version": "0.1.0",
4
+ "description": "Log in, list and export Microsoft OneNote notebooks as Obsidian Markdown - one CLI over @msout/microsoft-webauth, @msout/microsoft-onenote-list-notebooks and @msout/microsoft-onenote-export-notebook.",
5
+ "main": "src/index.js",
6
+ "exports": {
7
+ ".": "./src/index.js",
8
+ "./cli": "./src/index.js",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "bin": {
12
+ "ms-onenote-exporter": "src/index.js",
13
+ "microsoft-onenote-exporter": "src/index.js"
14
+ },
15
+ "files": [
16
+ "src/",
17
+ "README.md",
18
+ "LICENSE",
19
+ "NOTICE.md",
20
+ "CHANGELOG.md"
21
+ ],
22
+ "engines": {
23
+ "node": ">=24.0.0"
24
+ },
25
+ "scripts": {
26
+ "start": "node src/index.js",
27
+ "test": "jest",
28
+ "test:watch": "jest --watch",
29
+ "lint": "eslint .",
30
+ "use:local": "sh scripts/use-local-packages.sh",
31
+ "use:published": "sh scripts/use-published-packages.sh"
32
+ },
33
+ "keywords": [
34
+ "microsoft",
35
+ "onenote",
36
+ "playwright",
37
+ "export",
38
+ "obsidian",
39
+ "markdown",
40
+ "cli"
41
+ ],
42
+ "author": "msout@tuta.io",
43
+ "license": "MIT",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/Ms-OneNote-Exporter/microsoft-onenote-exporter.git"
47
+ },
48
+ "bugs": {
49
+ "url": "https://github.com/Ms-OneNote-Exporter/microsoft-onenote-exporter/issues"
50
+ },
51
+ "homepage": "https://github.com/Ms-OneNote-Exporter/microsoft-onenote-exporter#readme",
52
+ "dependencies": {
53
+ "@msout/microsoft-onenote-export-notebook": "0.3.7",
54
+ "@msout/microsoft-onenote-list-notebooks": "0.0.6",
55
+ "@msout/microsoft-webauth": "0.1.8",
56
+ "chalk": "^4.1.2",
57
+ "commander": "^14.0.3",
58
+ "fs-extra": "^11.3.3",
59
+ "playwright": "1.63.0"
60
+ },
61
+ "overrides": {
62
+ "playwright": "1.63.0",
63
+ "playwright-core": "1.63.0"
64
+ },
65
+ "devDependencies": {
66
+ "eslint": "^9.39.5",
67
+ "jest": "^29.7.0"
68
+ },
69
+ "publishConfig": {
70
+ "access": "public"
71
+ }
72
+ }
package/src/config.js ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @fileoverview Configuration for the umbrella CLI.
3
+ * @copyright 2026 msout
4
+ */
5
+ const os = require('os');
6
+ const path = require('path');
7
+
8
+ /**
9
+ * Where the three step packages should write their logs.
10
+ *
11
+ * Each of them is a separately installable package with its own logger, and each
12
+ * one is a singleton constructed the moment it is required. This function has to
13
+ * run before any of them is loaded, which is why src/index.js calls it at the
14
+ * very top rather than passing a path around: by the time a step is invoked the
15
+ * log directories have already been decided.
16
+ *
17
+ * The default is <cwd>/logs rather than a per-package directory because all three
18
+ * steps write `app.log` into the same directory, so a pipeline run produces one
19
+ * log with the steps in order rather than three logs in three places.
20
+ */
21
+ function resolveLogDir() {
22
+ const override = process.env.ONENOTE_EXPORT_LOG_DIR;
23
+ if (override && override.trim()) {
24
+ return path.resolve(override.trim());
25
+ }
26
+ return path.resolve(process.cwd(), 'logs');
27
+ }
28
+
29
+ /**
30
+ * Points the three step packages at this process's log directory.
31
+ *
32
+ * Setting the environment variable rather than passing an option is not a
33
+ * shortcut, it is the only channel those packages have: each resolves its log
34
+ * directory at require time from ONENOTE_EXPORT_LOG_DIR, and none of them accept
35
+ * a path as an argument. Idempotent, and it never overrides a value the caller
36
+ * already set - a container or a CI step that mounts its own log volume keeps it.
37
+ *
38
+ * @param {string} [logDir] - Directory to use; resolved from the environment when omitted
39
+ * @returns {string} The absolute directory all four loggers will write to
40
+ */
41
+ function shareLogDir(logDir = resolveLogDir()) {
42
+ process.env.ONENOTE_EXPORT_LOG_DIR = logDir;
43
+ return logDir;
44
+ }
45
+
46
+ /** The saved session, shared by every step. */
47
+ function defaultAuthFile() {
48
+ const home = os.homedir();
49
+ return path.join(home, '.microsoft-webauth', 'auth-file.json');
50
+ }
51
+
52
+ /**
53
+ * Where to authenticate against.
54
+ *
55
+ * Both URLs are the ones the step packages pin. ONENOTE_URL is the pre-rebrand
56
+ * /notebooks path on purpose: Microsoft 365 Copilot moved the app to
57
+ * /copilotnotebooks but /notebooks still redirects there, so a login entered at
58
+ * the old path is detected as authenticated a few seconds later. Re-pinning it
59
+ * here would trade a working alias for a path that can move again - see
60
+ * ONENOTE_URL in @msout/microsoft-webauth.
61
+ */
62
+ const TARGETS = {
63
+ onenote: 'https://onenote.cloud.microsoft/notebooks',
64
+ outlook: 'https://outlook.cloud.microsoft/mail/',
65
+ };
66
+
67
+ /** Exit codes, so a caller can tell the outcomes apart without parsing output. */
68
+ const EXIT = {
69
+ ok: 0,
70
+ /** The command itself failed: bad arguments, unusable auth file, a dead run. */
71
+ failed: 1,
72
+ /** Unattended use asked for without a way to choose a notebook. */
73
+ usage: 2,
74
+ /** Finished, but pages, sections or groups are missing. Passed through from
75
+ * @msout/microsoft-onenote-export-notebook, which distinguishes this from a
76
+ * total failure because partial output is still worth keeping. */
77
+ partial: 3,
78
+ };
79
+
80
+ module.exports = { resolveLogDir, shareLogDir, defaultAuthFile, TARGETS, EXIT };
package/src/index.js ADDED
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @fileoverview CLI over the three OneNote exporter steps.
4
+ * @copyright 2026 msout
5
+ *
6
+ * One binary, three steps. The packages it drives remain separately installable
7
+ * and separately tested; what this adds is a single command that can do all
8
+ * three, one Playwright install, one Docker image, and one log per run.
9
+ */
10
+ const { shareLogDir } = require('./config');
11
+
12
+ // Before anything else, and before any step package is loaded: each of those
13
+ // decides its log directory at require time, from the environment. Doing this
14
+ // lower in the file would leave three loggers pointing somewhere this run never
15
+ // writes. See shareLogDir for why an environment variable is the only channel
16
+ // available.
17
+ const LOG_DIR = shareLogDir();
18
+
19
+ const { program } = require('commander');
20
+ const logger = require('./logger');
21
+ const { EXIT, TARGETS } = require('./config');
22
+ const { version: PKG_VERSION } = require('../package.json');
23
+
24
+ // Read from ./config rather than from the step package, so `--help` does not
25
+ // pull in Playwright and three loggers just to print a default path. steps/auth
26
+ // re-exports the same value for anyone who wants the package's own answer.
27
+ const { defaultAuthFile } = require('./steps/auth');
28
+
29
+ /**
30
+ * Options every step understands.
31
+ *
32
+ * Declared once and reused, because a flag that works on `login` but not on
33
+ * `export` is indistinguishable from a bug in the tool.
34
+ */
35
+ function sharedOptions(command) {
36
+ return command
37
+ // No "(default: ...)" in the description: commander appends the real
38
+ // value itself, and spelling it out here printed it twice.
39
+ .option('--auth-file <path>', 'Path to the saved session', defaultAuthFile())
40
+ .option('--notheadless', 'Show the browser. Required for an interactive login, which cannot run headless')
41
+ .option('--dodump', 'Write the HTML of each page to the log directory, for debugging')
42
+ .option('-v, --verbose', 'Include debug output')
43
+ .option('-q, --quiet', 'Warnings and errors only');
44
+ }
45
+
46
+ /**
47
+ * Applies the verbosity flags.
48
+ *
49
+ * Sets this process's logger and the environment variable the export step reads,
50
+ * so one flag configures all four loggers instead of two of them.
51
+ */
52
+ function applyVerbosity(options) {
53
+ if (options.verbose) {
54
+ logger.setLevel('debug');
55
+ process.env.ONENOTE_EXPORT_LOG_LEVEL = 'debug';
56
+ } else if (options.quiet) {
57
+ logger.setLevel('warn');
58
+ process.env.ONENOTE_EXPORT_LOG_LEVEL = 'warn';
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Runs one step and turns its result into this process's exit code.
64
+ *
65
+ * Every failure path ends here, which is why there is exactly one place that
66
+ * decides the status: a command that reported an error and exited 0 is how a
67
+ * pipeline ends up treating a failed listing as a pass.
68
+ */
69
+ async function run(step) {
70
+ try {
71
+ const { exitCode } = await step();
72
+ process.exitCode = exitCode;
73
+ } catch (e) {
74
+ logger.error(`${program.name()} failed:`, e);
75
+ process.exitCode = EXIT.failed;
76
+ }
77
+ }
78
+
79
+ program
80
+ .name('microsoft-onenote-exporter')
81
+ .description('Sign in to Microsoft OneNote, list the notebooks on the account, and export one to Markdown.')
82
+ .version(PKG_VERSION)
83
+ .addHelpText('after', `
84
+ Also installed as: ms-onenote-exporter
85
+
86
+ Logs for every step of a run are written to one app.log in:
87
+ ${LOG_DIR}
88
+
89
+ A typical first run:
90
+ microsoft-onenote-exporter login
91
+ microsoft-onenote-exporter list
92
+ microsoft-onenote-exporter export --notebook "Work"
93
+ `);
94
+
95
+ sharedOptions(
96
+ program
97
+ .command('login')
98
+ .description('Sign in to Microsoft and save the session for the other steps')
99
+ )
100
+ .option('--email <email>', 'Account email. Without it, the browser opens and you sign in yourself')
101
+ .option('--password <password>', 'Account password. Supplying it makes the login headless')
102
+ .option('--against <target>', `Service to authenticate against: ${Object.keys(TARGETS).join(' | ')}`, 'onenote')
103
+ .option('--screenshot', 'With --dodump, also save a PNG of each dumped page')
104
+ .action(async (options) => {
105
+ applyVerbosity(options);
106
+ await run(() => require('./steps/auth').login(options));
107
+ });
108
+
109
+ sharedOptions(
110
+ program
111
+ .command('check')
112
+ .description('Report whether the saved session is still valid')
113
+ )
114
+ .option('--against <target>', `Service to check: ${Object.keys(TARGETS).join(' | ')}`, 'onenote')
115
+ .action(async (options) => {
116
+ applyVerbosity(options);
117
+ await run(() => require('./steps/auth').check(options));
118
+ });
119
+
120
+ sharedOptions(
121
+ program
122
+ .command('logout')
123
+ .description('Delete the saved session and its metadata')
124
+ )
125
+ .action(async (options) => {
126
+ applyVerbosity(options);
127
+ await run(() => require('./steps/auth').logout(options));
128
+ });
129
+
130
+ sharedOptions(
131
+ program
132
+ .command('list')
133
+ .description('List the notebooks on the signed-in account')
134
+ )
135
+ .action(async (options) => {
136
+ applyVerbosity(options);
137
+ await run(() => require('./steps/list').list(options));
138
+ });
139
+
140
+ sharedOptions(
141
+ program
142
+ .command('export')
143
+ .description('Export one notebook to Obsidian-flavoured Markdown')
144
+ )
145
+ .option('--notebook <name>', 'Notebook to export, by name (skips the interactive picker)')
146
+ .option('--notebook-link <url>', 'Notebook to export, by URL (skips listing and picking)')
147
+ .option('--output-dir <path>', 'Where to write the Markdown (default: ./output)')
148
+ .option('--nopassasked', 'Skip password-protected sections instead of asking for the password')
149
+ .option('--non-interactive', 'Run unattended: requires --notebook or --notebook-link, and implies --nopassasked')
150
+ .action(async (options) => {
151
+ applyVerbosity(options);
152
+
153
+ // Fail before a browser is launched. Left to the export step, an
154
+ // unattended run with no way to choose a notebook reaches the
155
+ // interactive picker and waits forever - which in a container looks
156
+ // exactly like a hung export.
157
+ if (options.nonInteractive && !(options.notebook || options.notebookLink)) {
158
+ logger.error('--non-interactive requires --notebook <name> or --notebook-link <url>.');
159
+ logger.error('Without one of them the export would stop at the interactive notebook picker.');
160
+ process.exitCode = EXIT.usage;
161
+ return;
162
+ }
163
+
164
+ await run(() => require('./steps/export').exportNotebook(options));
165
+ });
166
+
167
+ // A Playwright target that dies mid-run - the tab closed, the renderer crashed,
168
+ // the browser was killed - also rejects one of Playwright's own internal
169
+ // promises, which nothing awaits. Node treats that as fatal and kills the
170
+ // process with a bare stack trace, so a dead OneNote tab would end a run with no
171
+ // message, no summary and no status. Report it like any other failure and let
172
+ // the run finish unwinding.
173
+ process.on('unhandledRejection', (reason) => {
174
+ logger.error('Unexpected internal failure (this is a bug):', reason);
175
+ process.exitCode = EXIT.failed;
176
+ });
177
+
178
+ program.parseAsync().catch((e) => {
179
+ // The handlers above report their own failures; this is the net for anything
180
+ // else, including a throw while arguments were parsed.
181
+ logger.error('Failed:', e);
182
+ process.exitCode = EXIT.failed;
183
+ });
package/src/logger.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @fileoverview Logger for the umbrella CLI.
3
+ * @copyright 2026 msout
4
+ *
5
+ * A fourth logger, which is not the point of this exercise but is unavoidable
6
+ * while the three steps are separate packages: none of them exports its logger,
7
+ * and each constructs its own singleton at require time. This one handles the
8
+ * umbrella's own messages, and points the other three at the same directory (see
9
+ * src/config.js shareLogDir) so a run produces one app.log.
10
+ */
11
+ const chalk = require('chalk');
12
+ const fs = require('fs-extra');
13
+ const path = require('path');
14
+ const { resolveLogDir } = require('./config');
15
+
16
+ /** Severity order, lowest first. A message is emitted if its level >= the threshold. */
17
+ const LEVELS = { debug: 10, info: 20, step: 20, success: 20, warn: 30, error: 40 };
18
+
19
+ class Logger {
20
+ constructor() {
21
+ this.months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
22
+ this.logDir = resolveLogDir();
23
+ this.logFilePath = path.join(this.logDir, 'app.log');
24
+ this.level = Logger._initialLevel();
25
+ fs.ensureDirSync(this.logDir);
26
+ }
27
+
28
+ /**
29
+ * Reads the initial threshold from the environment.
30
+ *
31
+ * ONENOTE_EXPORT_LOG_LEVEL is the name the export step already uses, so
32
+ * `--verbose` and a container's environment variable set the same thing for
33
+ * all four loggers rather than two of them.
34
+ */
35
+ static _initialLevel() {
36
+ const raw = (process.env.ONENOTE_EXPORT_LOG_LEVEL || '').toLowerCase().trim();
37
+ if (raw === 'debug' || raw === 'verbose') return LEVELS.debug;
38
+ if (raw === 'warn' || raw === 'quiet') return LEVELS.warn;
39
+ if (raw === 'error') return LEVELS.error;
40
+ return LEVELS.info;
41
+ }
42
+
43
+ /**
44
+ * Raises or lowers the threshold at runtime.
45
+ * @param {string} name - One of debug|info|warn|error
46
+ */
47
+ setLevel(name) {
48
+ const level = LEVELS[(name || '').toLowerCase()];
49
+ if (level !== undefined) {
50
+ this.level = level;
51
+ }
52
+ }
53
+
54
+ /** True when a message at `level` should be emitted. */
55
+ _enabled(level) {
56
+ return (LEVELS[level] ?? LEVELS.info) >= this.level;
57
+ }
58
+
59
+ _timestamp() {
60
+ const now = new Date();
61
+ return `[${this.months[now.getMonth()]} ${String(now.getDate()).padStart(2, '0')} ${now.toTimeString().split(' ')[0]}]`;
62
+ }
63
+
64
+ _stripColors(str) {
65
+ // eslint-disable-next-line no-control-regex
66
+ return str.replace(/\u001b\[[0-9;]*m/g, '');
67
+ }
68
+
69
+ _write(level, message, color) {
70
+ if (!this._enabled(level)) return;
71
+
72
+ const stamp = this._timestamp();
73
+ const body = message instanceof Error
74
+ ? (message.stack || message.message)
75
+ : (typeof message === 'string' ? message : JSON.stringify(message, null, 2));
76
+
77
+ const plain = body.split('\n').map((line) => `[${level}] ${line}`).join('\n');
78
+ const colored = body.split('\n').map((line) => `${chalk.gray(stamp)} ${color(`[${level}]`)} ${line}`).join('\n');
79
+
80
+ // mode only applies at creation; an app.log from another logger in this
81
+ // same directory may predate it and be 0644.
82
+ fs.appendFileSync(this.logFilePath, `${plain}\n`, { mode: 0o600 });
83
+
84
+ const stream = level === 'error' ? process.stderr : process.stdout;
85
+ stream.write(`${colored}\n`);
86
+ }
87
+
88
+ debug(message) { this._write('debug', message, chalk.gray); }
89
+ info(message) { this._write('info', message, chalk.blue); }
90
+ step(message) { this._write('step', message, chalk.magenta); }
91
+ success(message) { this._write('success', message, chalk.green); }
92
+ warn(message) { this._write('warn', message, chalk.yellow); }
93
+ error(message) { this._write('error', message, chalk.red); }
94
+ }
95
+
96
+ module.exports = new Logger();
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @fileoverview Adapters over @msout/microsoft-webauth.
3
+ * @copyright 2026 msout
4
+ *
5
+ * This is a translation layer and nothing more: it renames the CLI's options into
6
+ * the names that package expects, calls one function, and returns. All the
7
+ * behaviour - the login flow, the blocking-screen handling, the credential
8
+ * redaction in dumps - stays in that package, which is separately installable and
9
+ * separately tested. If a rule ever needs to exist in two places, it belongs
10
+ * here as a comment and not as code.
11
+ */
12
+ const logger = require('../logger');
13
+ const { TARGETS, EXIT } = require('../config');
14
+
15
+ /**
16
+ * Loads the package.
17
+ *
18
+ * Required lazily, inside the command, rather than at the top of the module: the
19
+ * package's logger is a singleton built at require time and decides its log
20
+ * directory from the environment, so it must not be loaded before src/index.js
21
+ * has set ONENOTE_EXPORT_LOG_DIR. Loading it at import time would put its app.log
22
+ * somewhere this run never looks.
23
+ */
24
+ function load() {
25
+ return require('@msout/microsoft-webauth');
26
+ }
27
+
28
+ /** The auth file default, read from the package that owns the convention. */
29
+ function defaultAuthFile() {
30
+ return require('@msout/microsoft-webauth/config').DEFAULT_AUTH_FILE;
31
+ }
32
+
33
+ /** Resolves the --against value to a URL the login flow will accept. */
34
+ function targetUrl(against) {
35
+ return against === 'outlook' ? TARGETS.outlook : TARGETS.onenote;
36
+ }
37
+
38
+ /**
39
+ * Signs in and writes the session to the auth file.
40
+ *
41
+ * Note what decides headless: that package runs headless only when it is given
42
+ * both an email and a password. Without them it shows the browser, because an
43
+ * interactive login needs somewhere to type the password and click the MFA
44
+ * prompt. `--notheadless` is therefore only meaningful alongside credentials, and
45
+ * saying so is better than letting a flag appear to do nothing.
46
+ *
47
+ * @param {object} options - CLI options
48
+ * @returns {Promise<object>} The auth metadata the package recorded
49
+ */
50
+ async function login(options) {
51
+ const { login: doLogin } = load();
52
+
53
+ if (!options.notheadless && !(options.email && options.password)) {
54
+ logger.debug('No --email/--password given, so the browser will be shown for an interactive login.');
55
+ }
56
+ if (options.screenshot && !options.dodump) {
57
+ logger.warn('--screenshot only applies to the pages written by --dodump; enabling --dodump as well.');
58
+ options.dodump = true;
59
+ }
60
+
61
+ await doLogin({
62
+ email: options.email,
63
+ password: options.password,
64
+ targetUrl: targetUrl(options.against),
65
+ authFile: options.authFile,
66
+ notheadless: options.notheadless,
67
+ dodump: options.dodump,
68
+ screenshot: options.screenshot,
69
+ });
70
+
71
+ return { exitCode: EXIT.ok };
72
+ }
73
+
74
+ /**
75
+ * Reports whether the saved session still works.
76
+ *
77
+ * Worth being precise about what this proves: the underlying check launches a
78
+ * browser, loads the auth file and follows the redirect. If it redirects to a
79
+ * Microsoft login page the session is dead, and the package deletes the stale
80
+ * file. So a `false` here does not just mean "not authenticated", it means the
81
+ * file has been cleaned up and the next command needs a fresh `login`.
82
+ *
83
+ * @param {object} options - CLI options
84
+ * @returns {Promise<object>} { authenticated, exitCode }
85
+ */
86
+ async function check(options) {
87
+ const { checkAuth, getAuthMeta } = load();
88
+
89
+ const authenticated = await checkAuth(targetUrl(options.against), options.authFile);
90
+
91
+ if (!authenticated) {
92
+ logger.error('Not authenticated, or the saved session has expired.');
93
+ logger.error('Run "microsoft-onenote-exporter login" to create a new one.');
94
+ return { authenticated: false, exitCode: EXIT.failed };
95
+ }
96
+
97
+ logger.success(`Authenticated${options.against === 'outlook' ? ' against Outlook' : ''}.`);
98
+
99
+ const meta = await getAuthMeta(options.authFile);
100
+ if (meta && meta.email) {
101
+ logger.info(`Signed in as: ${meta.email}`);
102
+ }
103
+ if (meta && meta.loginTime) {
104
+ logger.info(`Session started: ${new Date(meta.loginTime).toLocaleString()}`);
105
+ }
106
+
107
+ return { authenticated: true, exitCode: EXIT.ok };
108
+ }
109
+
110
+ /**
111
+ * Deletes the saved session and its metadata.
112
+ *
113
+ * @param {object} options - CLI options
114
+ * @returns {Promise<object>} { exitCode }
115
+ */
116
+ async function logout(options) {
117
+ const { logout: doLogout } = load();
118
+
119
+ await doLogout(options.authFile);
120
+ logger.success('Signed out. The saved session has been deleted.');
121
+ return { exitCode: EXIT.ok };
122
+ }
123
+
124
+ module.exports = { login, check, logout, defaultAuthFile, targetUrl };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @fileoverview Adapter over @msout/microsoft-onenote-export-notebook.
3
+ * @copyright 2026 msout
4
+ */
5
+ const logger = require('../logger');
6
+ const { EXIT } = require('../config');
7
+
8
+ /**
9
+ * Loads the package.
10
+ *
11
+ * Lazily, for the same reason as in steps/auth.js: its logger is a singleton
12
+ * constructed at require time and reads the log directory from the environment,
13
+ * so it must not be loaded before src/index.js has set ONENOTE_EXPORT_LOG_DIR.
14
+ */
15
+ function load() {
16
+ return require('@msout/microsoft-onenote-export-notebook');
17
+ }
18
+
19
+ /**
20
+ * Exports one notebook to Obsidian-flavoured Markdown.
21
+ *
22
+ * On exit codes. That package uses three - 1 for a run that produced nothing
23
+ * usable, 2 for bad arguments, 3 for a run that finished while missing pages,
24
+ * sections or groups - and it reports 3 by setting `process.exitCode` as a side
25
+ * effect from inside the export, then returns its stats object.
26
+ *
27
+ * The return value is the better channel and this uses it: exitCodeForStats is
28
+ * exported precisely so a caller can ask the same question the package asked,
29
+ * and reading a global that a library mutated is how a composed pipeline ends up
30
+ * with the wrong status. src/index.js sets the real exit code afterwards, which
31
+ * also means the side effect is overwritten rather than inherited.
32
+ *
33
+ * Three is deliberately not collapsed into one here. Partial output is worth
34
+ * keeping and re-running, and a caller that cannot tell "partial" from "nothing"
35
+ * has to discard both.
36
+ *
37
+ * @param {object} options - CLI options
38
+ * @returns {Promise<object>} { stats, exitCode }
39
+ */
40
+ async function exportNotebook(options) {
41
+ const { runExport, exitCodeForStats } = load();
42
+
43
+ const stats = await runExport({
44
+ authFile: options.authFile,
45
+ notebook: options.notebook,
46
+ notebookLink: options.notebookLink,
47
+ exportDir: options.outputDir,
48
+ notheadless: options.notheadless,
49
+ dodump: options.dodump,
50
+ nopassasked: options.nopassasked,
51
+ nonInteractive: options.nonInteractive,
52
+ });
53
+
54
+ // The package's own rule, not a copy of it: if the two ever disagree, this
55
+ // one follows the package.
56
+ const exitCode = exitCodeForStats(stats);
57
+
58
+ if (exitCode === EXIT.partial) {
59
+ logger.warn('The export finished, but some pages, sections or groups are missing.');
60
+ logger.warn('The notes that were written are complete, and name any asset they could not download.');
61
+ logger.warn('Re-run the export to try again.');
62
+ }
63
+
64
+ return { stats, exitCode };
65
+ }
66
+
67
+ module.exports = { exportNotebook };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @fileoverview Adapter over @msout/microsoft-onenote-list-notebooks.
3
+ * @copyright 2026 msout
4
+ */
5
+ const logger = require('../logger');
6
+ const { EXIT } = require('../config');
7
+
8
+ /**
9
+ * Loads the package.
10
+ *
11
+ * Lazily, for the same reason as in steps/auth.js: its logger is a singleton
12
+ * constructed at require time and reads the log directory from the environment,
13
+ * so it must not be loaded before src/index.js has set ONENOTE_EXPORT_LOG_DIR.
14
+ */
15
+ function load() {
16
+ return require('@msout/microsoft-onenote-list-notebooks');
17
+ }
18
+
19
+ /**
20
+ * Lists the notebooks on the signed-in account.
21
+ *
22
+ * An empty result is not an error: it means the account is authenticated and has
23
+ * no notebooks, which is a different situation from a failure and is reported as
24
+ * success. The distinction matters because the export step can still work from a
25
+ * `--notebook-link` when the listing comes back empty - Microsoft for the web
26
+ * does not always list a notebook the account can otherwise open directly.
27
+ *
28
+ * @param {object} options - CLI options
29
+ * @returns {Promise<object>} { notebooks, exitCode }
30
+ */
31
+ async function list(options) {
32
+ const { listNotebooks } = load();
33
+
34
+ const notebooks = await listNotebooks({
35
+ authFile: options.authFile,
36
+ notheadless: options.notheadless,
37
+ dodump: options.dodump,
38
+ });
39
+
40
+ if (notebooks.length === 0) {
41
+ logger.warn('No notebooks were found on this account.');
42
+ logger.warn('If you know the notebook exists, export it by URL:');
43
+ logger.warn(' microsoft-onenote-exporter export --notebook-link <url>');
44
+ } else {
45
+ logger.step('\nAvailable notebooks:');
46
+ notebooks.forEach((nb, index) => {
47
+ logger.info(`${index + 1}. ${nb.name}`);
48
+ logger.debug(` ${nb.url}`);
49
+ });
50
+ }
51
+
52
+ return { notebooks, exitCode: EXIT.ok };
53
+ }
54
+
55
+ module.exports = { list };