gdrive-publish0.8.0
Publish markdown, CSV and Excel files to Google Drive as native Google Docs and Sheets, rewriting relative links to real Doc URLs with stable sharing links
Untested on omp
This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.
Reports 0
No reports yet.
Readme
gdrive-publish
Publish a folder of markdown, CSV and Excel files to Google Drive as native
Google types — markdown becomes Google Docs, CSV/XLSX become Google Sheets —
while relative links between your files are rewritten to the real Google Doc
URLs. A toc.md linking to ./details.md becomes a Doc whose link opens the
details Doc.
Re-publishing updates the same Drive files in place, so URLs you shared stay valid.
Status: complete — core library, CLI, and pi extension. All behaviour below is verified by
npm test(offline) and was smoke-tested against a real Drive.
Using it from pi
The gdrive_publish tool is inert unless this project is set up for
publishing: it is registered only when the working directory (or an ancestor,
up to the repo root) has a .gdrive-manifest.json and a cached OAuth token
exists. Sessions in unrelated repositories never see a tool that can write to
Drive.
| Situation | What is registered |
|---|---|
| unrelated directory | /gdrive-publish-init, /gdrive-publish-status only |
| manifest present, not authenticated | the same two commands only |
| manifest + token present | additionally the gdrive_publish tool |
/gdrive-publish-status # what is missing, and why
/gdrive-publish-init Project X Docs # create the Drive folder + manifest
/reload # picks up the now-registered tool
gdrive_publish { mode: "plan" } # read-only, never prompts
gdrive_publish { mode: "publish" } # asks for confirmation first
Writes are confirm-gated: in an interactive session publish shows the
create/update/orphan counts via ctx.ui.confirm, and in a non-interactive
session it refuses unless assumeYes: true is passed explicitly. The
accompanying skill tells the agent to
plan first, to never prune unasked, and how to explain each warning.
How it works
Publishing is two-phase, which is what makes link rewriting possible at all:
phase 0 scan the tree, build the link graph, run the full offline
link/image analysis (no network)
phase 1 create folders; create EMPTY Doc shells for each .md;
convert-upload CSV/XLSX -> every fileId is now known
-> manifest checkpointed with `pending` markers
phase 2 rewrite relative links to real Doc/Sheet URLs, inline images,
fill content via files.update -> fileIds (and URLs) unchanged
phase 3 final manifest write + report
plan and publish --dry-run run phase 0 only and report exactly the
diagnostics a real publish would, so the preview never understates the outcome.
Drive cannot pre-allocate ids for Workspace types (files.generateIds does not
support convert-on-upload), so a single-pass "rewrite then upload" is
impossible. Phase 1 exists purely to learn the ids.
Install / auth
Distributed as the zero-dependency npm package gdrive-publish (Node >= 20;
ships compiled JS in dist/ while the TypeScript sources stay for pi and
monorepo use):
npx gdrive-publish login # zero-install, one-time browser consent
npm i -g gdrive-publish # or install once; scope: drive.file
Run straight from the monorepo without any install:
node extensions/gdrive-publish/cli.ts login
Installing it as a pi package (pi install npm:gdrive-publish) registers the
extension; never do that on a machine that also loads this monorepo via static
paths - see the repo README's double-load warning.
Credentials are resolved in this order, and the chosen source is reported by name — never by value:
GDRIVE_PUBLISH_CLIENT_ID+GDRIVE_PUBLISH_CLIENT_SECRETGDRIVE_PUBLISH_CLIENT_SECRET_FILE=/path/to/client_secret.json~/.config/gdrive-publish/client_secret.json- an rclone drive remote (
GDRIVE_PUBLISH_RCLONE_REMOTE=name, or the only one)
Tokens are cached per client identity as
~/.config/gdrive-publish/token-<sha256(client_id)[0:8]>.json (mode 0600), so
switching credential sources costs one extra consent instead of an opaque
invalid_client error.
Usage
The command surface is defined once in commands.ts and rendered everywhere
(gdrive-publish help, help <command>, <command> --help, strict flag
validation, and the generated reference in the manual), so help cannot drift
from behaviour:
gdrive-publish help # overview
gdrive-publish help publish # flags, examples, exit codes for one command
Every command except login is fully non-interactive and scriptable from CI
and git hooks. The task-oriented guide (first publish, day-two workflows,
troubleshooting) is the manual.
Raw uploads (opt-in)
Files whose type has no native Drive conversion (no markdown→Doc, no
csv/xlsx→Sheet) can still be published as raw binary uploads -
stored as-is in Drive under their exact filename, updated in place with
stable ids, and linkable from your markdown (links rewrite to
https://drive.google.com/file/d/<id>/view).
This is strictly opt-in per pattern, via .gdrive-publish.json next to
your docs (committed like the manifest):
{ "rawPatterns": ["*.zip", "data/*.json", "archives/**"] }
Glob semantics follow .gitignore intuition: patterns without / match
the basename at any depth, patterns with / are anchored to the root,
** spans segments. A pattern can never hijack a native type - *.json
will not touch a .csv. Without a config, nothing new ever leaves the
machine; the default is still skip-with-warning.
Images are claimable too - a *.png pattern uploads them as raw files
in addition to their embedding into Docs (embeds always inline from
disk, because Drive URLs do not render in converted Docs). Unreferenced,
unclaimed images produce an IMAGE_UNREFERENCED warning instead of being
silently dropped.
gdrive-publish plan docs/ prints a tip when unsupported files exist, and
plan docs/ --suggest-config creates or extends the config with patterns
derived from the extensions actually found (merging, never removing), then
shows the file and the resulting plan - verify the patterns before the
next run. The suggestion also cautions when a derived pattern looks
credential-adjacent (.env, .pem, id_rsa, …).
The manifest
.gdrive-manifest.json sits next to your docs and should be committed — it
is the identity map from source path to Drive fileId, and therefore the reason
URLs stay stable for everyone. publish never touches git; it just tells you
the file changed.
It is written via temp-file + atomic rename, and checkpointed after phase 1
with pending: true entries. If a run dies between creating shells and filling
them, the next run fills those exact Docs instead of creating duplicates.
Behaviour worth knowing
| Input | Result |
|---|---|
[x](./other.md) |
rewritten to the other file's Doc URL |
[x](./data.csv) |
rewritten to the Sheet URL |
[x](./other.md#section) |
links to the top of the Doc, anchor dropped (warned) |
[x](../outside.md) |
left verbatim (warned) — outside the publish root |
[x](https://…) |
untouched |
[x](./missing.md) |
left verbatim (warned) |
links inside ``` fences / code spans |
never rewritten |
 |
inlined as a base64 data: URI so Drive embeds a real copy |
| YAML front matter | stripped (Drive mangles it); title: becomes the Doc name |
| Doc name | front-matter title → first H1 → filename stem |
.csv |
dialect + encoding sniffed, normalised to UTF-8 comma CSV, then converted |
.xlsx / .xls |
uploaded as-is and converted (macros/formatting lost, warned) |
unsupported files (.txt, .zip, .json, …) |
skipped with a warning, or uploaded as raw files when matched by a rawPatterns entry in .gdrive-publish.json |
| local file deleted | reported as an orphan; only --prune removes it, and only to trash |
| file changed in Drive | local content wins, loud warning naming the editor (Drive keeps revision history) |
Why images are inlined
The obvious approach — upload the image, share it, link to
drive.google.com/uc?export=view&id=… — cannot work on a Workspace that
blocks anyone-with-link sharing: POST /permissions {type:"anyone"} fails with
publishOutNotPermitted, and Drive's importer fetches image URLs
anonymously, so even domain-shared images render as a 1×1 placeholder.
Base64 data: URIs embed byte-identical images with no permissions at all.
See spike/FINDINGS.md.
Guardrails: a warning above 2 MB per image, and a hard error above 10 MB of prepared document payload (base64 inflates by ~33%).
Scope and blast radius
Only https://www.googleapis.com/auth/drive.file is requested: the tool can
only see files it created itself. init creates its own root folder, which you
can then move and share freely in Drive — access survives the move (verified).
Nothing is ever hard-deleted; --prune uses trash.
Tests
npm test # 118 tests, offline, no credentials, ~2s
npm run typecheck # 0 errors
npm run lint # biome
CI (.github/workflows/ci.yml) runs all three on Node 24 for every push and
pull request. Node 24 is required because everything here is plain TypeScript
executed through Node's native type stripping - there is no build step.
| Suite | Tests | What it guards |
|---|---|---|
links.test.ts |
10 | link/image parsing; never rewriting inside fenced blocks or inline code spans |
csv-naming.test.ts |
12 | encoding/delimiter sniffing, quoting, front matter, Doc naming |
scan-prepare.test.ts |
20 | file classification, ignore rules, path-escape refusal, image inlining, size caps, plan-time analysis mode |
auth.test.ts |
13 | credential-chain precedence, the GOCSPX-/rclone reveal rule, per-client token keying |
drive-manifest-report.test.ts |
18 | exact REST request shapes, error-reason parsing, atomic/sorted manifest IO, report rendering |
publish.test.ts |
17 | two-phase ordering, update-in-place, crash recovery, orphan safety, trash-not-delete, diagnostic dedupe |
cli.test.ts |
18 | exit-code contract (0/1/2/3), --json shape, dry-run purity, plan/publish diagnostic parity, secrets never printed |
extension.test.ts |
10 | the tool is registered only in configured projects; writes are confirm-gated |
The Drive REST layer is tested by swapping globalThis.fetch, so request
shapes are asserted without network access; auth.ts is tested with a stub
rclone on PATH; the CLI is tested as a real subprocess because exit codes
are the contract for CI and git hooks.
Manual sandbox
sandbox/docs is a committed, deliberately awkward
doc tree (cross-type links, anchors, escapes, code-fenced links, a latin-1
semicolon CSV, an xlsx, an obvious test image) for publishing to a real Drive
and inspecting by hand. Its README documents the exact plan output to expect,
so a missing warning is visibly a regression.
Deliberate gaps
- No live Drive tier in CI. Nothing automatically verifies Google's actual
behaviour, so the fake
DriveClientcan drift. Publishsandbox/docsand look at the result before releasing anything that touches upload or conversion. - Image embedding must be verified by rendering (export PDF -> rasterise ->
inspect pixels). An
<img>tag, an![]()construct, or aword/media/*part in a docx export all report success on a document that is visibly blank - three false positives that a human eye caught during the spike. - Heading anchors are dropped rather than resolved to Google Docs heading ids.
--repairis not implemented: an inaccessible or trashed Drive file produces aDRIVE_FILE_GONEerror instead of being recreated.
Layout
core/ pure logic, zero pi imports, injectable DriveClient
auth.ts credential chain, token cache, loopback consent
drive.ts DriveClient interface + REST implementation
links.ts link parsing/rewriting (code-fence aware)
naming.ts front matter + Doc naming
csv.ts dialect sniffing (row-consistency based) + normalisation
prepare.ts front-matter stripping, link rewriting, image inlining
publish.ts planner + two-phase publisher
manifest.ts atomic, sorted, checkpointed manifest IO
report.ts text and JSON rendering
cli.ts non-interactive CLI
index.ts pi extension: readiness gating, typed tool, confirm prompts
test/ offline suite + fake Drive client