@workerstack/repo 0.0.0-stage → 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 +45 -0
- package/LICENSE +21 -0
- package/README.md +0 -3
- package/agents/global-settings/antigravity.json +3 -0
- package/agents/global-settings/claude.json +6 -0
- package/agents/global-settings/codex.toml +2 -0
- package/agents/global-settings/cursor.json +3 -0
- package/agents/global-settings/grok.toml +36 -0
- package/agents/global-settings/manifest.json +36 -0
- package/agents/grok/hooks/orchestrate-parent.json +60 -0
- package/agents/grok/hooks/rtk.json +16 -0
- package/agents/grok/hooks/rulesync.json +3 -0
- package/agents/grok/roles/scout.toml +5 -0
- package/agents/hooks.jsonc +67 -0
- package/agents/mcp-policy.jsonc +16 -0
- package/agents/rules/overview.md +21 -0
- package/agents/settings/claude.json +5 -0
- package/agents/skills/ask/SKILL.md +13 -0
- package/agents/skills/auto/SKILL.md +13 -0
- package/agents/skills/away/SKILL.md +19 -0
- package/agents/skills/blast-radius/SKILL.md +54 -0
- package/agents/skills/bro/SKILL.md +9 -0
- package/agents/skills/browser/SKILL.md +16 -0
- package/agents/skills/clasp/SKILL.md +11 -0
- package/agents/skills/commit/SKILL.md +35 -0
- package/agents/skills/commit-and-push/SKILL.md +9 -0
- package/agents/skills/commit-push/SKILL.md +59 -0
- package/agents/skills/commit-push-deploy/SKILL.md +74 -0
- package/agents/skills/commit-push-release/SKILL.md +75 -0
- package/agents/skills/correct/SKILL.md +39 -0
- package/agents/skills/do/SKILL.md +21 -0
- package/agents/skills/explain/SKILL.md +13 -0
- package/agents/skills/google-workspace/SKILL.md +12 -0
- package/agents/skills/handoff/SKILL.md +81 -0
- package/agents/skills/implement-plan/SKILL.md +29 -0
- package/agents/skills/implement-roadmap/SKILL.md +27 -0
- package/agents/skills/jev/SKILL.md +17 -0
- package/agents/skills/listen/SKILL.md +15 -0
- package/agents/skills/pickup/SKILL.md +58 -0
- package/agents/skills/principle-attack-the-premise/SKILL.md +24 -0
- package/agents/skills/principle-boundary-discipline/SKILL.md +38 -0
- package/agents/skills/principle-build-the-lever/SKILL.md +28 -0
- package/agents/skills/principle-encode-lessons-in-structure/SKILL.md +15 -0
- package/agents/skills/principle-exhaust-the-design-space/SKILL.md +12 -0
- package/agents/skills/principle-experience-first/SKILL.md +22 -0
- package/agents/skills/principle-explain-the-number/SKILL.md +21 -0
- package/agents/skills/principle-fix-root-causes/SKILL.md +24 -0
- package/agents/skills/principle-foundational-thinking/SKILL.md +25 -0
- package/agents/skills/principle-guard-the-context-window/SKILL.md +16 -0
- package/agents/skills/principle-laziness-protocol/SKILL.md +13 -0
- package/agents/skills/principle-make-operations-idempotent/SKILL.md +25 -0
- package/agents/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md +18 -0
- package/agents/skills/principle-minimize-reader-load/SKILL.md +22 -0
- package/agents/skills/principle-model-the-domain/SKILL.md +26 -0
- package/agents/skills/principle-never-block-on-the-human/SKILL.md +17 -0
- package/agents/skills/principle-outcome-oriented-execution/SKILL.md +25 -0
- package/agents/skills/principle-prove-it-works/SKILL.md +25 -0
- package/agents/skills/principle-redesign-from-first-principles/SKILL.md +18 -0
- package/agents/skills/principle-separate-before-serializing-shared-state/SKILL.md +20 -0
- package/agents/skills/principle-sequence-verifiable-units/SKILL.md +22 -0
- package/agents/skills/principle-subtract-before-you-add/SKILL.md +21 -0
- package/agents/skills/principle-test-behavior-not-implementation/SKILL.md +24 -0
- package/agents/skills/principle-type-system-discipline/SKILL.md +17 -0
- package/agents/skills/review-plan/SKILL.md +28 -0
- package/agents/skills/review-roadmap/SKILL.md +28 -0
- package/agents/skills/show-me/SKILL.md +21 -0
- package/agents/skills/speak/SKILL.md +15 -0
- package/agents/skills/su/SKILL.md +11 -0
- package/agents/skills/teach/SKILL.md +25 -0
- package/agents/skills/tmp/SKILL.md +54 -0
- package/agents/skills/typescript-best-practices/SKILL.md +32 -0
- package/agents/skills/typescript-best-practices/references/patterns.md +328 -0
- package/agents/skills/unslop/SKILL.md +62 -0
- package/agents/skills/write-plan/SKILL.md +33 -0
- package/agents/skills/write-roadmap/SKILL.md +66 -0
- package/agents/subagents/implementer-deep.md +57 -0
- package/agents/subagents/implementer-fast.md +57 -0
- package/agents/subagents/orchestrator.md +43 -0
- package/agents/subagents/researcher.md +47 -0
- package/agents/subagents/scout.md +44 -0
- package/bin/repo.ts +4 -0
- package/cli/actionlint-default.test.ts +38 -0
- package/cli/actionlint-download.test.ts +162 -0
- package/cli/actionlint-real.test.ts +33 -0
- package/cli/actionlint.constants.ts +43 -0
- package/cli/actionlint.test.ts +108 -0
- package/cli/actionlint.ts +178 -0
- package/cli/bin.test.ts +54 -0
- package/cli/bin.ts +40 -0
- package/cli/check-changelog.test.ts +40 -0
- package/cli/check-changelog.ts +12 -0
- package/cli/check-comments.test.ts +157 -0
- package/cli/check-comments.ts +123 -0
- package/cli/check-env-modules.test.ts +85 -0
- package/cli/check-env-modules.ts +56 -0
- package/cli/check-magic.test.ts +139 -0
- package/cli/check-magic.ts +120 -0
- package/cli/check-manifests.constants.ts +4 -0
- package/cli/check-manifests.test.ts +210 -0
- package/cli/check-manifests.ts +71 -0
- package/cli/check-nightly.test.ts +103 -0
- package/cli/check-nightly.ts +37 -0
- package/cli/check-ratchets-missing.test.ts +46 -0
- package/cli/check-ratchets-mutation.test.ts +49 -0
- package/cli/check-ratchets.constants.ts +20 -0
- package/cli/check-ratchets.test.ts +190 -0
- package/cli/check-ratchets.ts +193 -0
- package/cli/check-rejections.test.ts +220 -0
- package/cli/check-rejections.ts +153 -0
- package/cli/check-reports.ts +37 -0
- package/cli/check-tsconfig.test.ts +507 -0
- package/cli/check-tsconfig.ts +205 -0
- package/cli/commands.test.ts +114 -0
- package/cli/commands.ts +218 -0
- package/cli/commit-changelog.ts +27 -0
- package/cli/commit-msg-changelog.test.ts +93 -0
- package/cli/commit-msg-range.test.ts +126 -0
- package/cli/commit-msg-red-proof.test.ts +147 -0
- package/cli/commit-msg-repo.test.ts +153 -0
- package/cli/commit-msg.test.ts +101 -0
- package/cli/commit-msg.ts +194 -0
- package/cli/commit-range.constants.ts +17 -0
- package/cli/commit-range.ts +57 -0
- package/cli/config-knip.test.ts +70 -0
- package/cli/config-nightly.test.ts +84 -0
- package/cli/config-tdd.test.ts +46 -0
- package/cli/config.test.ts +576 -0
- package/cli/config.ts +261 -0
- package/cli/conventional.ts +9 -0
- package/cli/coverage-config.ts +62 -0
- package/cli/coverage.constants.ts +6 -0
- package/cli/coverage.test.ts +199 -0
- package/cli/coverage.ts +127 -0
- package/cli/env.constants.ts +10 -0
- package/cli/env.test.ts +56 -0
- package/cli/env.ts +72 -0
- package/cli/fixtures/base-config.ts +13 -0
- package/cli/fixtures/gate-world.ts +48 -0
- package/cli/fixtures/knip-run.ts +38 -0
- package/cli/fixtures/mutate-fakes.ts +137 -0
- package/cli/fixtures/planted-repo.ts +50 -0
- package/cli/fixtures/publish-world.ts +71 -0
- package/cli/fixtures/red-proof-repo.ts +72 -0
- package/cli/fixtures/release-context.ts +15 -0
- package/cli/fixtures/release-world.ts +111 -0
- package/cli/fixtures/stub-bin.ts +40 -0
- package/cli/guard-diagnostics.test.ts +157 -0
- package/cli/guard-diagnostics.ts +195 -0
- package/cli/init.test.ts +487 -0
- package/cli/init.ts +275 -0
- package/cli/knip-check-ignores.test.ts +106 -0
- package/cli/knip-check-real.test.ts +65 -0
- package/cli/knip-check.constants.ts +46 -0
- package/cli/knip-check.test.ts +114 -0
- package/cli/knip-check.ts +169 -0
- package/cli/knip-schema.ts +67 -0
- package/cli/known-failures.constants.ts +22 -0
- package/cli/known-failures.test.ts +138 -0
- package/cli/known-failures.ts +131 -0
- package/cli/lint/as-needs-reason.ts +41 -0
- package/cli/lint/boundaries.ts +73 -0
- package/cli/lint/class-file.ts +39 -0
- package/cli/lint/config-file.ts +32 -0
- package/cli/lint/entry-file.ts +54 -0
- package/cli/lint/env-in-config.ts +66 -0
- package/cli/lint/filename-matches-export.ts +63 -0
- package/cli/lint/index-file.ts +22 -0
- package/cli/lint/index.ts +47 -0
- package/cli/lint/is-reason.ts +6 -0
- package/cli/lint/manifest.ts +46 -0
- package/cli/lint/names.ts +59 -0
- package/cli/lint/no-commented-out-code.ts +105 -0
- package/cli/lint/no-mock-of-subject.ts +64 -0
- package/cli/lint/no-module-side-effects.ts +94 -0
- package/cli/lint/no-module-state.ts +58 -0
- package/cli/lint/no-side-effect-import.ts +17 -0
- package/cli/lint/no-v8-ignore.ts +18 -0
- package/cli/lint/script-rules.ts +9 -0
- package/cli/lint/skip-on-platform.constants.ts +8 -0
- package/cli/lint/skip-on-platform.ts +118 -0
- package/cli/lint/todo-needs-id.ts +63 -0
- package/cli/lint/types-file.ts +28 -0
- package/cli/lint-schema.ts +32 -0
- package/cli/magic/fixtures/magic-repo.ts +64 -0
- package/cli/magic/magic-hit.ts +2 -0
- package/cli/magic/magic-nodes.ts +44 -0
- package/cli/magic/magic-numbers.test.ts +99 -0
- package/cli/magic/magic-numbers.ts +117 -0
- package/cli/magic/magic-strings.test.ts +38 -0
- package/cli/magic/magic-strings.ts +81 -0
- package/cli/main.test.ts +853 -0
- package/cli/main.ts +292 -0
- package/cli/mutate-args.ts +79 -0
- package/cli/mutate-check.test.ts +147 -0
- package/cli/mutate-deps.ts +42 -0
- package/cli/mutate-edges.test.ts +150 -0
- package/cli/mutate-files.test.ts +100 -0
- package/cli/mutate-files.ts +31 -0
- package/cli/mutate-floors.test.ts +91 -0
- package/cli/mutate-floors.ts +104 -0
- package/cli/mutate-real-deps.ts +57 -0
- package/cli/mutate-real.test.ts +96 -0
- package/cli/mutate-report.ts +96 -0
- package/cli/mutate-run.ts +128 -0
- package/cli/mutate-shards.ts +64 -0
- package/cli/mutate.constants.ts +67 -0
- package/cli/mutate.test.ts +182 -0
- package/cli/mutate.ts +64 -0
- package/cli/nested-vitest-flags.ts +8 -0
- package/cli/nightly-config.constants.ts +14 -0
- package/cli/nightly-config.ts +62 -0
- package/cli/prompt.test.ts +202 -0
- package/cli/prompt.ts +1 -0
- package/cli/red-proof-edges.test.ts +78 -0
- package/cli/red-proof-failures.test.ts +82 -0
- package/cli/red-proof-tree.test.ts +157 -0
- package/cli/red-proof-verdict.test.ts +44 -0
- package/cli/red-proof.constants.ts +14 -0
- package/cli/red-proof.ts +202 -0
- package/cli/release/changelog-parse.ts +83 -0
- package/cli/release/changelog.test.ts +141 -0
- package/cli/release/changelog.ts +106 -0
- package/cli/release/gate-refusals.test.ts +85 -0
- package/cli/release/gate.test.ts +63 -0
- package/cli/release/gate.ts +70 -0
- package/cli/release/git.ts +66 -0
- package/cli/release/github.test.ts +128 -0
- package/cli/release/github.ts +46 -0
- package/cli/release/list-dir.test.ts +11 -0
- package/cli/release/list-dir.ts +6 -0
- package/cli/release/main.test.ts +48 -0
- package/cli/release/main.ts +51 -0
- package/cli/release/manifest.test.ts +24 -0
- package/cli/release/manifest.ts +34 -0
- package/cli/release/pack.test.ts +107 -0
- package/cli/release/pack.ts +76 -0
- package/cli/release/plan.ts +127 -0
- package/cli/release/publish-run.test.ts +98 -0
- package/cli/release/publish.test.ts +127 -0
- package/cli/release/publish.ts +125 -0
- package/cli/release/refuse.ts +5 -0
- package/cli/release/release-messages.constants.ts +96 -0
- package/cli/release/release-refusals.test.ts +149 -0
- package/cli/release/release.constants.ts +128 -0
- package/cli/release/release.test.ts +199 -0
- package/cli/release/release.ts +145 -0
- package/cli/release/version.test.ts +118 -0
- package/cli/release/version.ts +69 -0
- package/cli/release/wait.test.ts +101 -0
- package/cli/release/wait.ts +95 -0
- package/cli/script-diagnostics.test.ts +76 -0
- package/cli/script-diagnostics.ts +62 -0
- package/cli/scripts/board.ts +519 -0
- package/cli/scripts/browser.ts +994 -0
- package/cli/scripts/change-set.ts +105 -0
- package/cli/scripts/check-globals.ts +199 -0
- package/cli/scripts/ci-skips.constants.ts +17 -0
- package/cli/scripts/ci-skips.ts +118 -0
- package/cli/scripts/ci-wait-run.ts +28 -0
- package/cli/scripts/ci-wait.constants.ts +44 -0
- package/cli/scripts/ci-wait.ts +175 -0
- package/cli/scripts/ci.constants.ts +15 -0
- package/cli/scripts/ci.ts +208 -0
- package/cli/scripts/clean.ts +363 -0
- package/cli/scripts/comments-step.ts +15 -0
- package/cli/scripts/doctor.ts +828 -0
- package/cli/scripts/emit-agent-shell-tools.ts +91 -0
- package/cli/scripts/emit-mcp-policy.ts +402 -0
- package/cli/scripts/folder-map.ts +353 -0
- package/cli/scripts/gate-fat-tools.ts +1095 -0
- package/cli/scripts/gitignore-guard.ts +338 -0
- package/cli/scripts/google-workspace.ts +779 -0
- package/cli/scripts/hook-command.ts +72 -0
- package/cli/scripts/hook-input.ts +178 -0
- package/cli/scripts/hook-io.constants.ts +5 -0
- package/cli/scripts/hook-io.ts +46 -0
- package/cli/scripts/hooks-union.ts +469 -0
- package/cli/scripts/interactive.ts +30 -0
- package/cli/scripts/jev.ts +420 -0
- package/cli/scripts/job.ts +934 -0
- package/cli/scripts/jsonc.ts +9 -0
- package/cli/scripts/lessons.ts +564 -0
- package/cli/scripts/lint-baseline.ts +233 -0
- package/cli/scripts/lint-main.ts +24 -0
- package/cli/scripts/lint-skips.ts +139 -0
- package/cli/scripts/lint.constants.ts +6 -0
- package/cli/scripts/lint.ts +142 -0
- package/cli/scripts/listen.ts +962 -0
- package/cli/scripts/managed-text.ts +47 -0
- package/cli/scripts/orchestrate-parent.ts +1155 -0
- package/cli/scripts/parse-need.ts +81 -0
- package/cli/scripts/persist-handoff.ts +228 -0
- package/cli/scripts/persist-implementation.ts +276 -0
- package/cli/scripts/persist-plan.ts +888 -0
- package/cli/scripts/persist-roadmap.ts +478 -0
- package/cli/scripts/persist-tmp.ts +177 -0
- package/cli/scripts/persist.ts +54 -0
- package/cli/scripts/phase-files.ts +121 -0
- package/cli/scripts/prompt.ts +38 -0
- package/cli/scripts/purge-global-mcps.ts +313 -0
- package/cli/scripts/rejections-step.ts +37 -0
- package/cli/scripts/rtk-grok.ts +102 -0
- package/cli/scripts/scrub-spawn-prompt.ts +103 -0
- package/cli/scripts/scrub-text-lib.ts +58 -0
- package/cli/scripts/session-map.ts +69 -0
- package/cli/scripts/setup.ts +862 -0
- package/cli/scripts/skill-conflicts.ts +82 -0
- package/cli/scripts/speak.ts +370 -0
- package/cli/scripts/strategy.ts +168 -0
- package/cli/scripts/sync-global-settings.ts +316 -0
- package/cli/scripts/turn-steps.ts +249 -0
- package/cli/scripts/turn.ts +157 -0
- package/cli/scripts/typecheck-step.ts +43 -0
- package/cli/scripts/vscode-tasks.ts +175 -0
- package/cli/scripts/workspace-root.ts +160 -0
- package/cli/scripts/worktree.ts +806 -0
- package/cli/spawn.test.ts +34 -0
- package/cli/spawn.ts +27 -0
- package/cli/staged-change.constants.ts +11 -0
- package/cli/staged-change.ts +79 -0
- package/cli/stryker-preload.test.ts +50 -0
- package/cli/stryker-preload.ts +9 -0
- package/cli/sync/agents/frontmatter.test.ts +197 -0
- package/cli/sync/agents/frontmatter.ts +140 -0
- package/cli/sync/agents/hooks.test.ts +517 -0
- package/cli/sync/agents/hooks.ts +401 -0
- package/cli/sync/agents/rules.test.ts +229 -0
- package/cli/sync/agents/rules.ts +84 -0
- package/cli/sync/agents/sources.test.ts +375 -0
- package/cli/sync/agents/sources.ts +244 -0
- package/cli/sync/agents/subagents.test.ts +149 -0
- package/cli/sync/agents/subagents.ts +158 -0
- package/cli/sync/agents-text.test.ts +31 -0
- package/cli/sync/agents-text.ts +36 -0
- package/cli/sync/fixtures/copied.README.md +35 -0
- package/cli/sync/fixtures/copied.tgz +0 -0
- package/cli/sync/fixtures/maps/golden/INDEX.md +22 -0
- package/cli/sync/fixtures/maps-ws.tgz +0 -0
- package/cli/sync/git-files.test.ts +340 -0
- package/cli/sync/git-files.ts +197 -0
- package/cli/sync/git-hooks.test.ts +94 -0
- package/cli/sync/golden.test.ts +15 -0
- package/cli/sync/hooks-mode.test.ts +99 -0
- package/cli/sync/hosts.ts +28 -0
- package/cli/sync/knip-config.constants.ts +4 -0
- package/cli/sync/knip-config.test.ts +147 -0
- package/cli/sync/knip-config.ts +108 -0
- package/cli/sync/manifest.test.ts +78 -0
- package/cli/sync/manifest.ts +60 -0
- package/cli/sync/maps.test.ts +304 -0
- package/cli/sync/maps.ts +297 -0
- package/cli/sync/migrate.test.ts +2000 -0
- package/cli/sync/migrate.ts +1104 -0
- package/cli/sync/nightly-jobs.test.ts +63 -0
- package/cli/sync/nightly.constants.ts +48 -0
- package/cli/sync/nightly.test.ts +100 -0
- package/cli/sync/nightly.ts +75 -0
- package/cli/sync/outputs.test.ts +351 -0
- package/cli/sync/outputs.ts +168 -0
- package/cli/sync/oxlint-config.test.ts +178 -0
- package/cli/sync/oxlint-config.ts +168 -0
- package/cli/sync/release-jobs.test.ts +29 -0
- package/cli/sync/release-reports.test.ts +31 -0
- package/cli/sync/release-workflow.constants.ts +61 -0
- package/cli/sync/release-workflow.test.ts +192 -0
- package/cli/sync/release-workflow.ts +86 -0
- package/cli/sync/release.constants.ts +93 -0
- package/cli/sync/release.test.ts +187 -0
- package/cli/sync/release.ts +112 -0
- package/cli/sync/repo-dir.test.ts +371 -0
- package/cli/sync/repo-dir.ts +205 -0
- package/cli/sync/standards.constants.ts +8 -0
- package/cli/sync/standards.test.ts +206 -0
- package/cli/sync/standards.ts +179 -0
- package/cli/sync/stock-history.json +2796 -0
- package/cli/sync/stock-history.test.ts +66 -0
- package/cli/sync/stock-history.ts +84 -0
- package/cli/sync/stock.test.ts +47 -0
- package/cli/sync/stock.ts +24 -0
- package/cli/sync/trees.test.ts +621 -0
- package/cli/sync/trees.ts +280 -0
- package/cli/sync/validate.test.ts +299 -0
- package/cli/sync/validate.ts +402 -0
- package/cli/sync/workspace.test.ts +180 -0
- package/cli/sync/workspace.ts +138 -0
- package/cli/sync/write.test.ts +341 -0
- package/cli/sync/write.ts +231 -0
- package/cli/sync.test.ts +1495 -0
- package/cli/sync.ts +360 -0
- package/cli/tdd-scope.test.ts +36 -0
- package/cli/tdd-scope.ts +41 -0
- package/cli/test-files.ts +16 -0
- package/cli/ts-program.ts +69 -0
- package/cli/tsconfig.constants.ts +8 -0
- package/cli/workerstack-config.types.ts +21 -0
- package/dist/cli/env.d.ts +10 -0
- package/dist/cli/env.js +64 -0
- package/dist/presets/temp-root-env.d.ts +10 -0
- package/dist/presets/temp-root-env.js +20 -0
- package/dist/presets/temp-root.constants.d.ts +6 -0
- package/dist/presets/temp-root.constants.js +6 -0
- package/dist/presets/temp-root.d.ts +2 -0
- package/dist/presets/temp-root.js +60 -0
- package/dist/presets/vitest.constants.d.ts +16 -0
- package/dist/presets/vitest.constants.js +16 -0
- package/dist/presets/vitest.d.ts +11 -0
- package/dist/presets/vitest.js +67 -0
- package/dist/presets/workers-plugin.d.ts +10 -0
- package/dist/presets/workers-plugin.js +54 -0
- package/package.json +141 -4
- package/references/INDEX.md +100 -0
- package/references/browser.md +66 -0
- package/references/cascade.md +15 -0
- package/references/clasp.md +21 -0
- package/references/cloudflare.md +36 -0
- package/references/google-workspace.md +79 -0
- package/references/hooks.md +103 -0
- package/references/implementation-format.md +119 -0
- package/references/jev.md +56 -0
- package/references/lessons.md +73 -0
- package/references/listen.md +80 -0
- package/references/map.md +107 -0
- package/references/output.md +47 -0
- package/references/plan-format.md +264 -0
- package/references/review-loop.md +48 -0
- package/references/roadmap-format.md +104 -0
- package/references/rtk.md +27 -0
- package/references/scope.md +76 -0
- package/references/setup.md +42 -0
- package/references/speak.md +59 -0
- package/tsconfig/base.json +28 -0
- package/tsconfig/bun.d.ts +1 -0
- package/tsconfig/bun.json +8 -0
- package/tsconfig/reset.d.ts +1 -0
- package/tsconfig/strict.json +12 -0
- package/tsconfig/web.json +7 -0
- package/tsconfig/worker.d.ts +1 -0
- package/tsconfig/worker.json +8 -0
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/** Google Workspace verbs: sheet/doc/slide/meeting/mail. */
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import http from "node:http";
|
|
7
|
+
import type { AddressInfo } from "node:net";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { isRecord } from "./hook-input.ts";
|
|
11
|
+
import { readEnv } from "../env.ts";
|
|
12
|
+
|
|
13
|
+
type Rec = Record<string, unknown>;
|
|
14
|
+
|
|
15
|
+
/** A stored OAuth refresh token plus where it was read from. */
|
|
16
|
+
export type Token = Rec & {
|
|
17
|
+
refresh_token: string;
|
|
18
|
+
client_id: string;
|
|
19
|
+
_path: string;
|
|
20
|
+
_clasprc?: true;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export interface SpawnSyncOpts {
|
|
24
|
+
stdio: "ignore";
|
|
25
|
+
windowsVerbatimArguments?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The slice of `spawnSync` this tool uses; `status` is the child's exit code. */
|
|
29
|
+
export type SpawnFn = (
|
|
30
|
+
cmd: string,
|
|
31
|
+
args: string[],
|
|
32
|
+
opts: { stdio?: "ignore" | "inherit"; encoding?: "utf8"; windowsVerbatimArguments?: boolean },
|
|
33
|
+
) => { status: number | null };
|
|
34
|
+
|
|
35
|
+
export interface FileSpec {
|
|
36
|
+
kind: string;
|
|
37
|
+
title?: string | undefined;
|
|
38
|
+
csv?: string | undefined;
|
|
39
|
+
body?: string | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MeetingSpec {
|
|
43
|
+
title?: string | undefined;
|
|
44
|
+
start?: string | undefined;
|
|
45
|
+
end?: string | undefined;
|
|
46
|
+
when?: string | undefined;
|
|
47
|
+
attendees?: string | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface GResult {
|
|
51
|
+
status: number;
|
|
52
|
+
json: Rec;
|
|
53
|
+
ok: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function rec(v: unknown): Rec {
|
|
57
|
+
return isRecord(v) ? v : {};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The `error.message` of a Google API reply, or the fallback. */
|
|
61
|
+
function apiMessage(json: unknown, fallback: string): string {
|
|
62
|
+
const m = rec(rec(json).error).message;
|
|
63
|
+
return m ? String(m) : fallback;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function errMessage(e: unknown): string {
|
|
67
|
+
const m = isRecord(e) ? e.message : undefined;
|
|
68
|
+
return String(m || e);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function jsonRecord(r: Response): Promise<Rec> {
|
|
72
|
+
const j: unknown = await r.json();
|
|
73
|
+
return rec(j);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const DRIVE = "https://www.googleapis.com/drive/v3";
|
|
77
|
+
const DRIVE_UPLOAD = "https://www.googleapis.com/upload/drive/v3";
|
|
78
|
+
const SHEETS = "https://sheets.googleapis.com/v4";
|
|
79
|
+
const DOCS = "https://docs.googleapis.com/v1";
|
|
80
|
+
const CALENDAR = "https://www.googleapis.com/calendar/v3";
|
|
81
|
+
const GMAIL = "https://gmail.googleapis.com/gmail/v1";
|
|
82
|
+
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
83
|
+
|
|
84
|
+
/** Extra clasp login scopes. Clasp's default consent is Apps Script + Drive only. */
|
|
85
|
+
export const EXTRA_SCOPES: string[] = [
|
|
86
|
+
"https://www.googleapis.com/auth/calendar",
|
|
87
|
+
"https://www.googleapis.com/auth/calendar.events",
|
|
88
|
+
"https://www.googleapis.com/auth/gmail.modify",
|
|
89
|
+
"https://www.googleapis.com/auth/gmail.send",
|
|
90
|
+
"https://www.googleapis.com/auth/spreadsheets",
|
|
91
|
+
"https://www.googleapis.com/auth/documents",
|
|
92
|
+
"https://www.googleapis.com/auth/presentations",
|
|
93
|
+
"https://www.googleapis.com/auth/forms.body",
|
|
94
|
+
"https://www.googleapis.com/auth/drive",
|
|
95
|
+
"https://www.googleapis.com/auth/contacts",
|
|
96
|
+
"https://www.googleapis.com/auth/tasks",
|
|
97
|
+
];
|
|
98
|
+
const MIME: Record<string, string> = {
|
|
99
|
+
sheet: "application/vnd.google-apps.spreadsheet",
|
|
100
|
+
doc: "application/vnd.google-apps.document",
|
|
101
|
+
slide: "application/vnd.google-apps.presentation",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
let accountId = "default";
|
|
105
|
+
|
|
106
|
+
export function setAccount(id: unknown): string {
|
|
107
|
+
const s = String(id || "default").trim() || "default";
|
|
108
|
+
if (!/^[a-zA-Z0-9_-]{1,40}$/.test(s)) {
|
|
109
|
+
throw new Error("bad --account (letters, numbers, _- only)");
|
|
110
|
+
}
|
|
111
|
+
accountId = s;
|
|
112
|
+
return accountId;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function stateHome(home: string = os.homedir()) {
|
|
116
|
+
const d = path.join(home, ".workerstack");
|
|
117
|
+
fs.mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
118
|
+
return d;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function accountDir(home: string = os.homedir()) {
|
|
122
|
+
return path.join(stateHome(home), "workspace", accountId);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function tokenStorePath(home: string = os.homedir()): string {
|
|
126
|
+
return path.join(accountDir(home), "tokens.json");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function credsDefaultPath(): string {
|
|
130
|
+
return path.join(accountDir(), "google-oauth.json");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function adcPath(home: string = os.homedir()) {
|
|
134
|
+
return path.join(home, ".config", "gcloud", "application_default_credentials.json");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function clasprcPath(home: string) {
|
|
138
|
+
return path.join(home, ".clasprc.json");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function readJson(p: string): unknown {
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function writeSecretJson(p: string, obj: unknown): void {
|
|
150
|
+
fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
|
|
151
|
+
fs.writeFileSync(p, JSON.stringify(obj, null, 2) + "\n", { mode: 0o600 });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function toToken(j: Rec, p: string, extra: { _clasprc?: true } = {}): Token | null {
|
|
155
|
+
if (typeof j.refresh_token !== "string" || !j.refresh_token) return null;
|
|
156
|
+
if (typeof j.client_id !== "string" || !j.client_id) return null;
|
|
157
|
+
return { ...j, refresh_token: j.refresh_token, client_id: j.client_id, _path: p, ...extra };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function tokenFromStore(home: string): Token | null {
|
|
161
|
+
const p = tokenStorePath(home);
|
|
162
|
+
const j = rec(readJson(p));
|
|
163
|
+
return toToken(j, p);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function tokenFromAdc(home: string): Token | null {
|
|
167
|
+
const p = adcPath(home);
|
|
168
|
+
const j = rec(readJson(p));
|
|
169
|
+
if (j.type === "authorized_user") return toToken(j, p);
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function tokenFromClasprc(home: string): Token | null {
|
|
174
|
+
const p = clasprcPath(home);
|
|
175
|
+
const t = rec(rec(rec(readJson(p)).tokens).default);
|
|
176
|
+
if (typeof t.refresh_token !== "string" || !t.refresh_token) return null;
|
|
177
|
+
// Clasp's file carries no client_id check; the refresh needs one, so it stays whatever the file holds.
|
|
178
|
+
return { ...t, refresh_token: t.refresh_token, client_id: String(t.client_id ?? ""), _path: p, _clasprc: true };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function loadToken(home: string = os.homedir()): Token | null {
|
|
182
|
+
return tokenFromStore(home) || tokenFromAdc(home) || tokenFromClasprc(home);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function publicFields(t: Token): Rec {
|
|
186
|
+
return Object.fromEntries(Object.entries(t).filter(([k]) => k !== "_path" && k !== "_clasprc"));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function persistToken(t: Token, access: unknown, expiry: unknown): void {
|
|
190
|
+
t.access_token = access;
|
|
191
|
+
t.expiry_date = expiry;
|
|
192
|
+
if (t._clasprc) {
|
|
193
|
+
const raw = rec(readJson(t._path));
|
|
194
|
+
const tokens = rec(raw.tokens);
|
|
195
|
+
tokens.default = publicFields(t);
|
|
196
|
+
raw.tokens = tokens;
|
|
197
|
+
writeSecretJson(t._path, raw);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const dest = t._path !== adcPath() ? t._path : tokenStorePath();
|
|
201
|
+
writeSecretJson(dest, publicFields(t));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function accessToken(): Promise<string> {
|
|
205
|
+
const t = loadToken();
|
|
206
|
+
if (!t) throw new Error("not logged in; run bun repo google-workspace login");
|
|
207
|
+
if (typeof t.access_token === "string" && t.access_token && Number(t.expiry_date) > Date.now() + 60_000) {
|
|
208
|
+
return t.access_token;
|
|
209
|
+
}
|
|
210
|
+
const body = new URLSearchParams({
|
|
211
|
+
client_id: t.client_id,
|
|
212
|
+
client_secret: String(t.client_secret || ""),
|
|
213
|
+
refresh_token: t.refresh_token,
|
|
214
|
+
grant_type: "refresh_token",
|
|
215
|
+
});
|
|
216
|
+
const r = await fetch(TOKEN_URL, {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
219
|
+
body,
|
|
220
|
+
});
|
|
221
|
+
const j = await jsonRecord(r);
|
|
222
|
+
if (typeof j.access_token !== "string" || !j.access_token) {
|
|
223
|
+
throw new Error(String(j.error_description || j.error || "token refresh failed"));
|
|
224
|
+
}
|
|
225
|
+
const expiry = j.expiry_date ? j.expiry_date : Date.now() + Number(j.expires_in || 3600) * 1000;
|
|
226
|
+
persistToken(t, j.access_token, expiry);
|
|
227
|
+
return j.access_token;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function whichGcloud(spawn: SpawnFn): boolean {
|
|
231
|
+
const r = spawn("gcloud", ["--version"], { encoding: "utf8" });
|
|
232
|
+
return r.status === 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function openUrl(
|
|
236
|
+
url: string,
|
|
237
|
+
{
|
|
238
|
+
platform = process.platform,
|
|
239
|
+
spawnSyncFn = spawnSync,
|
|
240
|
+
}: { platform?: NodeJS.Platform; spawnSyncFn?: (cmd: string, args: string[], opts: SpawnSyncOpts) => unknown } = {},
|
|
241
|
+
): void {
|
|
242
|
+
if (platform === "darwin") {
|
|
243
|
+
spawnSyncFn("open", [url], { stdio: "ignore" });
|
|
244
|
+
} else if (platform === "win32") {
|
|
245
|
+
spawnSyncFn("cmd", ["/c", "start", "", url], {
|
|
246
|
+
stdio: "ignore",
|
|
247
|
+
windowsVerbatimArguments: true,
|
|
248
|
+
});
|
|
249
|
+
} else {
|
|
250
|
+
spawnSyncFn("xdg-open", [url], { stdio: "ignore" });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function loginLoopback(
|
|
255
|
+
credsPath: string,
|
|
256
|
+
home: string = os.homedir(),
|
|
257
|
+
spawn: SpawnFn = spawnSync,
|
|
258
|
+
): Promise<void> {
|
|
259
|
+
const raw = rec(readJson(credsPath));
|
|
260
|
+
const inst = rec(raw.installed || raw.web);
|
|
261
|
+
if (!inst.client_id) {
|
|
262
|
+
throw new Error("OAuth JSON needs installed.client_id (Desktop app download)");
|
|
263
|
+
}
|
|
264
|
+
const clientId = String(inst.client_id);
|
|
265
|
+
const clientSecret = String(inst.client_secret || "");
|
|
266
|
+
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
267
|
+
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
268
|
+
const server = http.createServer();
|
|
269
|
+
await new Promise<void>((res, rej) => {
|
|
270
|
+
server.listen(0, "127.0.0.1", res);
|
|
271
|
+
server.on("error", rej);
|
|
272
|
+
});
|
|
273
|
+
const { port } = server.address() /* listening on a TCP port, so never a pipe path or null */ as AddressInfo;
|
|
274
|
+
const redirect = `http://127.0.0.1:${port}`;
|
|
275
|
+
const scope = [
|
|
276
|
+
...EXTRA_SCOPES,
|
|
277
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
|
278
|
+
"https://www.googleapis.com/auth/userinfo.profile",
|
|
279
|
+
"openid",
|
|
280
|
+
].join(" ");
|
|
281
|
+
const auth =
|
|
282
|
+
"https://accounts.google.com/o/oauth2/v2/auth?" +
|
|
283
|
+
new URLSearchParams({
|
|
284
|
+
client_id: clientId,
|
|
285
|
+
redirect_uri: redirect,
|
|
286
|
+
response_type: "code",
|
|
287
|
+
access_type: "offline",
|
|
288
|
+
prompt: "consent",
|
|
289
|
+
scope,
|
|
290
|
+
code_challenge: challenge,
|
|
291
|
+
code_challenge_method: "S256",
|
|
292
|
+
}).toString();
|
|
293
|
+
const code = await new Promise<string>((resolve, reject) => {
|
|
294
|
+
const timer = setTimeout(() => reject(new Error("login timed out")), 180_000);
|
|
295
|
+
server.on("request", (req, res) => {
|
|
296
|
+
const u = new URL(String(req.url), redirect);
|
|
297
|
+
const c = u.searchParams.get("code");
|
|
298
|
+
const err = u.searchParams.get("error");
|
|
299
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
300
|
+
res.end(c ? "Logged in. Return to the terminal." : String(err || "missing code"));
|
|
301
|
+
clearTimeout(timer);
|
|
302
|
+
if (c) resolve(c);
|
|
303
|
+
else reject(new Error(err || "missing code"));
|
|
304
|
+
});
|
|
305
|
+
openUrl(auth, { spawnSyncFn: spawn });
|
|
306
|
+
}).finally(() => server.close());
|
|
307
|
+
const body = new URLSearchParams({
|
|
308
|
+
client_id: clientId,
|
|
309
|
+
client_secret: clientSecret,
|
|
310
|
+
code,
|
|
311
|
+
code_verifier: verifier,
|
|
312
|
+
redirect_uri: redirect,
|
|
313
|
+
grant_type: "authorization_code",
|
|
314
|
+
});
|
|
315
|
+
const r = await fetch(TOKEN_URL, {
|
|
316
|
+
method: "POST",
|
|
317
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
318
|
+
body,
|
|
319
|
+
});
|
|
320
|
+
const j = await jsonRecord(r);
|
|
321
|
+
if (!j.access_token) throw new Error(String(j.error_description || j.error || "token exchange failed"));
|
|
322
|
+
writeSecretJson(tokenStorePath(home), {
|
|
323
|
+
type: "authorized_user",
|
|
324
|
+
client_id: clientId,
|
|
325
|
+
client_secret: clientSecret,
|
|
326
|
+
refresh_token: j.refresh_token || loadToken(home)?.refresh_token,
|
|
327
|
+
access_token: j.access_token,
|
|
328
|
+
expiry_date: Date.now() + Number(j.expires_in || 3600) * 1000,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function loginGcloud(spawn: SpawnFn): void {
|
|
333
|
+
const scopes = [
|
|
334
|
+
"https://www.googleapis.com/auth/cloud-platform",
|
|
335
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
|
336
|
+
...EXTRA_SCOPES,
|
|
337
|
+
].join(",");
|
|
338
|
+
const r = spawn("gcloud", ["auth", "application-default", "login", `--scopes=${scopes}`], { stdio: "inherit" });
|
|
339
|
+
if (r.status !== 0) throw new Error("gcloud auth application-default login failed");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function runLogin(flags: Record<string, string>, spawn: SpawnFn): Promise<number> {
|
|
343
|
+
const creds = flags.creds || credsDefaultPath();
|
|
344
|
+
if (fs.existsSync(creds)) {
|
|
345
|
+
await loginLoopback(creds, os.homedir(), spawn);
|
|
346
|
+
return ok(`logged in\n${tokenStorePath()}`);
|
|
347
|
+
}
|
|
348
|
+
if (whichGcloud(spawn)) {
|
|
349
|
+
loginGcloud(spawn);
|
|
350
|
+
return ok("logged in via gcloud ADC");
|
|
351
|
+
}
|
|
352
|
+
return fail(`missing Desktop OAuth JSON (${creds}). setup: bun repo setup google-workspace`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function gfetch(
|
|
356
|
+
url: string,
|
|
357
|
+
init: { method?: string; body?: string; headers?: Record<string, string> } = {},
|
|
358
|
+
): Promise<GResult> {
|
|
359
|
+
const token = await accessToken();
|
|
360
|
+
const r = await fetch(url, {
|
|
361
|
+
...init,
|
|
362
|
+
headers: {
|
|
363
|
+
Authorization: "Bearer " + token,
|
|
364
|
+
...(init.body && typeof init.body === "string" ? { "Content-Type": "application/json" } : {}),
|
|
365
|
+
...(init.headers || {}),
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
const text = await r.text();
|
|
369
|
+
let json: Rec;
|
|
370
|
+
try {
|
|
371
|
+
const parsed: unknown = text ? JSON.parse(text) : null;
|
|
372
|
+
json = rec(parsed);
|
|
373
|
+
} catch {
|
|
374
|
+
json = { raw: text.slice(0, 400) };
|
|
375
|
+
}
|
|
376
|
+
return { status: r.status, json, ok: r.status >= 200 && r.status < 300 };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function parseArgv(argv: string[]): { _: string[]; flags: Record<string, string> } {
|
|
380
|
+
const out: { _: string[]; flags: Record<string, string> } = { _: [], flags: {} };
|
|
381
|
+
for (let i = 0; i < argv.length; i++) {
|
|
382
|
+
const a = argv[i]!; // i < argv.length
|
|
383
|
+
if (a === "--") {
|
|
384
|
+
out._.push(...argv.slice(i + 1));
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
if (a.startsWith("--")) {
|
|
388
|
+
const key = a.slice(2);
|
|
389
|
+
const nxt = argv[i + 1];
|
|
390
|
+
if (nxt && !nxt.startsWith("--")) {
|
|
391
|
+
out.flags[key] = nxt;
|
|
392
|
+
i += 1;
|
|
393
|
+
} else out.flags[key] = "1";
|
|
394
|
+
} else out._.push(a);
|
|
395
|
+
}
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function fail(msg: string): number {
|
|
400
|
+
process.stderr.write(msg.trimEnd() + "\n");
|
|
401
|
+
return 1;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function ok(msg: string): number {
|
|
405
|
+
process.stdout.write(msg.trimEnd() + "\n");
|
|
406
|
+
return 0;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function whoami(): Promise<number> {
|
|
410
|
+
const r = await gfetch("https://www.googleapis.com/oauth2/v2/userinfo");
|
|
411
|
+
if (!r.ok) return fail(apiMessage(r.json, "whoami failed"));
|
|
412
|
+
return ok(String(r.json.email || JSON.stringify(r.json)));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function createFile({ kind, title, csv, body }: FileSpec): Promise<Rec> {
|
|
416
|
+
const mime = MIME[kind];
|
|
417
|
+
if (!mime) throw new Error("unknown kind");
|
|
418
|
+
const name = title || `repo-${kind}`;
|
|
419
|
+
if (kind === "sheet" && csv) {
|
|
420
|
+
const meta = JSON.stringify({ name, mimeType: mime });
|
|
421
|
+
const boundary = "repo_ws_" + Date.now();
|
|
422
|
+
const blob =
|
|
423
|
+
`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${meta}\r\n` +
|
|
424
|
+
`--${boundary}\r\nContent-Type: text/csv\r\n\r\n${csv}\r\n` +
|
|
425
|
+
`--${boundary}--`;
|
|
426
|
+
const token = await accessToken();
|
|
427
|
+
const r = await fetch(`${DRIVE_UPLOAD}/files?uploadType=multipart&fields=id,name,webViewLink`, {
|
|
428
|
+
method: "POST",
|
|
429
|
+
headers: {
|
|
430
|
+
Authorization: "Bearer " + token,
|
|
431
|
+
"Content-Type": `multipart/related; boundary=${boundary}`,
|
|
432
|
+
},
|
|
433
|
+
body: blob,
|
|
434
|
+
});
|
|
435
|
+
const json = await jsonRecord(r);
|
|
436
|
+
if (!r.ok) throw new Error(apiMessage(json, "upload failed"));
|
|
437
|
+
return json;
|
|
438
|
+
}
|
|
439
|
+
if (kind === "doc" && body) {
|
|
440
|
+
const meta = JSON.stringify({ name, mimeType: mime });
|
|
441
|
+
const boundary = "repo_ws_" + Date.now();
|
|
442
|
+
const blob =
|
|
443
|
+
`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${meta}\r\n` +
|
|
444
|
+
`--${boundary}\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n${body}\r\n` +
|
|
445
|
+
`--${boundary}--`;
|
|
446
|
+
const token = await accessToken();
|
|
447
|
+
const r = await fetch(`${DRIVE_UPLOAD}/files?uploadType=multipart&fields=id,name,webViewLink`, {
|
|
448
|
+
method: "POST",
|
|
449
|
+
headers: {
|
|
450
|
+
Authorization: "Bearer " + token,
|
|
451
|
+
"Content-Type": `multipart/related; boundary=${boundary}`,
|
|
452
|
+
},
|
|
453
|
+
body: blob,
|
|
454
|
+
});
|
|
455
|
+
const json = await jsonRecord(r);
|
|
456
|
+
if (!r.ok) throw new Error(apiMessage(json, "upload failed"));
|
|
457
|
+
return json;
|
|
458
|
+
}
|
|
459
|
+
const r = await gfetch(`${DRIVE}/files?fields=id,name,webViewLink`, {
|
|
460
|
+
method: "POST",
|
|
461
|
+
body: JSON.stringify({ name, mimeType: mime }),
|
|
462
|
+
});
|
|
463
|
+
if (!r.ok) throw new Error(apiMessage(r.json, "create failed"));
|
|
464
|
+
return r.json;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function fileLine(j: Rec): string {
|
|
468
|
+
return `${j.name}\n${j.webViewLink || j.id}\nid=${j.id}`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function parseCsv(text: unknown): string[][] {
|
|
472
|
+
return String(text || "")
|
|
473
|
+
.split(/\r?\n/)
|
|
474
|
+
.map((line) => line.split(",").map((c) => c.trim()))
|
|
475
|
+
.filter((row) => row.some((c) => c !== ""));
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function sheetGet({ id, range }: { id?: string | undefined; range?: string | undefined }): Promise<number> {
|
|
479
|
+
if (!id) return fail("sheet get needs --id");
|
|
480
|
+
const r = await gfetch(`${SHEETS}/spreadsheets/${id}/values/${encodeURIComponent(range || "A1:Z50")}`);
|
|
481
|
+
if (!r.ok) return fail(apiMessage(r.json, "sheet get failed"));
|
|
482
|
+
const rows: unknown[] = Array.isArray(r.json.values) ? r.json.values : [];
|
|
483
|
+
if (!rows.length) return ok("(empty)");
|
|
484
|
+
return ok(rows.map((row) => (Array.isArray(row) ? row : []).join("\t")).join("\n"));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function sheetAppend({
|
|
488
|
+
id,
|
|
489
|
+
csv,
|
|
490
|
+
range,
|
|
491
|
+
}: {
|
|
492
|
+
id?: string | undefined;
|
|
493
|
+
csv?: string | undefined;
|
|
494
|
+
range?: string | undefined;
|
|
495
|
+
}): Promise<number> {
|
|
496
|
+
if (!id) return fail("sheet append needs --id");
|
|
497
|
+
const values = parseCsv(csv);
|
|
498
|
+
if (!values.length) return fail("sheet append needs --csv");
|
|
499
|
+
const r = await gfetch(
|
|
500
|
+
`${SHEETS}/spreadsheets/${id}/values/${encodeURIComponent(range || "A1")}:append?valueInputOption=USER_ENTERED`,
|
|
501
|
+
{ method: "POST", body: JSON.stringify({ values }) },
|
|
502
|
+
);
|
|
503
|
+
if (!r.ok) return fail(apiMessage(r.json, "sheet append failed"));
|
|
504
|
+
return ok(`appended ${String(rec(r.json.updates).updatedRows || values.length)}`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function sheetClear({ id, range }: { id?: string | undefined; range?: string | undefined }): Promise<number> {
|
|
508
|
+
if (!id) return fail("sheet clear needs --id");
|
|
509
|
+
const r = await gfetch(`${SHEETS}/spreadsheets/${id}/values/${encodeURIComponent(range || "A1:Z")}:clear`, {
|
|
510
|
+
method: "POST",
|
|
511
|
+
body: "{}",
|
|
512
|
+
});
|
|
513
|
+
if (!r.ok) return fail(apiMessage(r.json, "sheet clear failed"));
|
|
514
|
+
return ok(`cleared ${String(r.json.clearedRange || range || "A1:Z")}`);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function docAppend({ id, body }: { id?: string | undefined; body?: string | undefined }): Promise<number> {
|
|
518
|
+
if (!id) return fail("doc append needs --id");
|
|
519
|
+
const r = await gfetch(`${DOCS}/documents/${id}:batchUpdate`, {
|
|
520
|
+
method: "POST",
|
|
521
|
+
body: JSON.stringify({
|
|
522
|
+
requests: [
|
|
523
|
+
{
|
|
524
|
+
insertText: {
|
|
525
|
+
endOfSegmentLocation: {},
|
|
526
|
+
text: String(body ?? ""),
|
|
527
|
+
},
|
|
528
|
+
},
|
|
529
|
+
],
|
|
530
|
+
}),
|
|
531
|
+
});
|
|
532
|
+
if (!r.ok) return fail(apiMessage(r.json, "doc append failed"));
|
|
533
|
+
return ok(`appended id=${id}`);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function driveList({ query, n }: { query?: string | undefined; n?: string | undefined }): Promise<number> {
|
|
537
|
+
const q = new URLSearchParams({
|
|
538
|
+
pageSize: String(n || 10),
|
|
539
|
+
fields: "files(id,name,mimeType,webViewLink)",
|
|
540
|
+
});
|
|
541
|
+
if (query) q.set("q", query);
|
|
542
|
+
const r = await gfetch(`${DRIVE}/files?${q}`);
|
|
543
|
+
if (!r.ok) return fail(apiMessage(r.json, "drive list failed"));
|
|
544
|
+
const files: unknown[] = Array.isArray(r.json.files) ? r.json.files : [];
|
|
545
|
+
if (!files.length) return ok("no files");
|
|
546
|
+
return ok(
|
|
547
|
+
files
|
|
548
|
+
.map(rec)
|
|
549
|
+
.map((f) => `${String(f.name)}\t${String(f.id)}\t${String(f.webViewLink || f.mimeType || "")}`)
|
|
550
|
+
.join("\n"),
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function sheetSet({
|
|
555
|
+
id,
|
|
556
|
+
cell,
|
|
557
|
+
value,
|
|
558
|
+
}: {
|
|
559
|
+
id?: string | undefined;
|
|
560
|
+
cell?: string | undefined;
|
|
561
|
+
value?: string | undefined;
|
|
562
|
+
}): Promise<number> {
|
|
563
|
+
const range = encodeURIComponent(cell || "A1");
|
|
564
|
+
const r = await gfetch(`${SHEETS}/spreadsheets/${id}/values/${range}?valueInputOption=USER_ENTERED`, {
|
|
565
|
+
method: "PUT",
|
|
566
|
+
body: JSON.stringify({ values: [[value ?? ""]] }),
|
|
567
|
+
});
|
|
568
|
+
if (r.ok) return ok(`updated ${String(r.json.updatedRange || cell)}`);
|
|
569
|
+
const msg = apiMessage(r.json, "sheet set failed");
|
|
570
|
+
return fail(
|
|
571
|
+
msg.includes("has not been used") || msg.includes("SERVICE_DISABLED")
|
|
572
|
+
? `Sheets API is off on this OAuth project. Create the sheet with data instead: workspace sheet create --title … --csv "A1value"`
|
|
573
|
+
: msg,
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function toGCalStamp(iso: string): string {
|
|
578
|
+
const d = new Date(iso);
|
|
579
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
580
|
+
return d
|
|
581
|
+
.toISOString()
|
|
582
|
+
.replace(/[-:]/g, "")
|
|
583
|
+
.replace(/\.\d{3}Z$/, "Z");
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export function loginArgs(): string[] {
|
|
587
|
+
return ["login", "--extra-scopes", EXTRA_SCOPES.join(",")];
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function scopes(): Promise<number> {
|
|
591
|
+
const token = await accessToken();
|
|
592
|
+
const r = await fetch("https://oauth2.googleapis.com/tokeninfo?access_token=" + encodeURIComponent(token));
|
|
593
|
+
const j = await jsonRecord(r);
|
|
594
|
+
const list = String(j.scope || "")
|
|
595
|
+
.split(/\s+/)
|
|
596
|
+
.filter(Boolean)
|
|
597
|
+
.sort();
|
|
598
|
+
return ok(list.join("\n") || String(j.error || "no scopes"));
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function mailSend({
|
|
602
|
+
to,
|
|
603
|
+
subject,
|
|
604
|
+
body,
|
|
605
|
+
}: {
|
|
606
|
+
to?: string | undefined;
|
|
607
|
+
subject?: string | undefined;
|
|
608
|
+
body?: string | undefined;
|
|
609
|
+
}): Promise<number> {
|
|
610
|
+
if (!to) return fail("mail send needs --to");
|
|
611
|
+
const raw = [
|
|
612
|
+
`To: ${to}`,
|
|
613
|
+
`Subject: ${subject || "(no subject)"}`,
|
|
614
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
615
|
+
"",
|
|
616
|
+
body || "",
|
|
617
|
+
].join("\r\n");
|
|
618
|
+
const encoded = Buffer.from(raw).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
619
|
+
const r = await gfetch(`${GMAIL}/users/me/messages/send`, {
|
|
620
|
+
method: "POST",
|
|
621
|
+
body: JSON.stringify({ raw: encoded }),
|
|
622
|
+
});
|
|
623
|
+
if (!r.ok) return fail(apiMessage(r.json, "mail send failed"));
|
|
624
|
+
return ok(`sent id=${String(r.json.id)}`);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function mailList({ query, n }: { query?: string | undefined; n?: string | undefined }): Promise<number> {
|
|
628
|
+
const q = new URLSearchParams({ maxResults: String(n || 5) });
|
|
629
|
+
if (query) q.set("q", query);
|
|
630
|
+
const r = await gfetch(`${GMAIL}/users/me/messages?${q}`);
|
|
631
|
+
if (!r.ok) return fail(apiMessage(r.json, "mail list failed"));
|
|
632
|
+
const messages: unknown[] = Array.isArray(r.json.messages) ? r.json.messages : [];
|
|
633
|
+
const ids = messages.map((m) => rec(m).id);
|
|
634
|
+
return ok(ids.length ? ids.join("\n") : "no messages");
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function meetingCreate({ title, start, end, when, attendees }: MeetingSpec): Promise<number> {
|
|
638
|
+
let startIso = start;
|
|
639
|
+
let endIso = end;
|
|
640
|
+
if (!startIso && when) startIso = when;
|
|
641
|
+
if (!startIso) startIso = new Date(Date.now() + 3600_000).toISOString();
|
|
642
|
+
if (!endIso) endIso = new Date(new Date(startIso).getTime() + 3600_000).toISOString();
|
|
643
|
+
const event: {
|
|
644
|
+
summary: string;
|
|
645
|
+
start: { dateTime: string };
|
|
646
|
+
end: { dateTime: string };
|
|
647
|
+
attendees?: { email: string }[];
|
|
648
|
+
} = {
|
|
649
|
+
summary: title || "meeting",
|
|
650
|
+
start: { dateTime: startIso },
|
|
651
|
+
end: { dateTime: endIso },
|
|
652
|
+
};
|
|
653
|
+
const emails = String(attendees || "")
|
|
654
|
+
.split(/[,;\s]+/)
|
|
655
|
+
.map((e) => e.trim())
|
|
656
|
+
.filter(Boolean);
|
|
657
|
+
if (emails.length) {
|
|
658
|
+
event.attendees = emails.map((email) => ({ email }));
|
|
659
|
+
}
|
|
660
|
+
const r = await gfetch(`${CALENDAR}/calendars/primary/events`, {
|
|
661
|
+
method: "POST",
|
|
662
|
+
body: JSON.stringify(event),
|
|
663
|
+
});
|
|
664
|
+
if (r.ok) {
|
|
665
|
+
return ok(`${String(r.json.summary)}\n${String(r.json.htmlLink)}\nid=${String(r.json.id)}`);
|
|
666
|
+
}
|
|
667
|
+
const stamp0 = toGCalStamp(startIso);
|
|
668
|
+
const stamp1 = toGCalStamp(endIso);
|
|
669
|
+
const template =
|
|
670
|
+
"https://calendar.google.com/calendar/render?action=TEMPLATE" +
|
|
671
|
+
`&text=${encodeURIComponent(event.summary)}` +
|
|
672
|
+
(stamp0 && stamp1 ? `&dates=${stamp0}/${stamp1}` : "");
|
|
673
|
+
return fail(`Calendar API blocked (${apiMessage(r.json, String(r.status))}). Open to confirm:\n${template}`);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function listAccounts(): string {
|
|
677
|
+
const root = path.join(stateHome(), "workspace");
|
|
678
|
+
const names = new Set<string>();
|
|
679
|
+
try {
|
|
680
|
+
for (const ent of fs.readdirSync(root, { withFileTypes: true })) {
|
|
681
|
+
if (ent.isDirectory() && /^[a-zA-Z0-9_-]{1,40}$/.test(ent.name)) names.add(ent.name);
|
|
682
|
+
}
|
|
683
|
+
} catch {
|
|
684
|
+
/* none */
|
|
685
|
+
}
|
|
686
|
+
const lines = [...names].sort().map((n) => {
|
|
687
|
+
setAccount(n);
|
|
688
|
+
const creds = fs.existsSync(credsDefaultPath()) ? "oauth" : "-";
|
|
689
|
+
const tok = fs.existsSync(tokenStorePath()) ? "tokens" : "-";
|
|
690
|
+
return `${n} ${creds} ${tok}`;
|
|
691
|
+
});
|
|
692
|
+
setAccount("default");
|
|
693
|
+
return lines.length ? lines.join("\n") : "no accounts";
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function main(argv: string[], spawn: SpawnFn = spawnSync): Promise<number> {
|
|
697
|
+
const { _, flags } = parseArgv(argv);
|
|
698
|
+
setAccount(flags.account || readEnv().WORKSPACE_ACCOUNT || "default");
|
|
699
|
+
const verb = (_[0] || "").toLowerCase();
|
|
700
|
+
const noun = (_[1] || "").toLowerCase();
|
|
701
|
+
try {
|
|
702
|
+
if (verb === "accounts") return ok(listAccounts());
|
|
703
|
+
if (verb === "whoami") return await whoami();
|
|
704
|
+
if (verb === "scopes") return await scopes();
|
|
705
|
+
if (verb === "login") return await runLogin(flags, spawn);
|
|
706
|
+
if (verb === "mail" && noun === "send") {
|
|
707
|
+
return await mailSend({
|
|
708
|
+
to: flags.to,
|
|
709
|
+
subject: flags.subject,
|
|
710
|
+
body: flags.body,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
if (verb === "mail" && (noun === "list" || noun === "ls")) {
|
|
714
|
+
return await mailList({ query: flags.query || flags.q, n: flags.n });
|
|
715
|
+
}
|
|
716
|
+
if (verb === "sheet" && noun === "create") {
|
|
717
|
+
const j = await createFile({
|
|
718
|
+
kind: "sheet",
|
|
719
|
+
title: flags.title,
|
|
720
|
+
csv: flags.csv,
|
|
721
|
+
});
|
|
722
|
+
return ok(fileLine(j));
|
|
723
|
+
}
|
|
724
|
+
if (verb === "sheet" && noun === "set") {
|
|
725
|
+
return await sheetSet({
|
|
726
|
+
id: flags.id,
|
|
727
|
+
cell: flags.cell || flags.range,
|
|
728
|
+
value: flags.value ?? flags.a1,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
if (verb === "sheet" && noun === "get") {
|
|
732
|
+
return await sheetGet({ id: flags.id, range: flags.range || flags.cell });
|
|
733
|
+
}
|
|
734
|
+
if (verb === "sheet" && noun === "append") {
|
|
735
|
+
return await sheetAppend({
|
|
736
|
+
id: flags.id,
|
|
737
|
+
csv: flags.csv,
|
|
738
|
+
range: flags.range || flags.cell,
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
if (verb === "sheet" && noun === "clear") {
|
|
742
|
+
return await sheetClear({ id: flags.id, range: flags.range || flags.cell });
|
|
743
|
+
}
|
|
744
|
+
if (verb === "doc" && noun === "append") {
|
|
745
|
+
return await docAppend({ id: flags.id, body: flags.body });
|
|
746
|
+
}
|
|
747
|
+
if (verb === "drive" && (noun === "list" || noun === "ls" || !noun)) {
|
|
748
|
+
return await driveList({ query: flags.query || flags.q, n: flags.n });
|
|
749
|
+
}
|
|
750
|
+
if (verb === "doc" && noun === "create") {
|
|
751
|
+
const j = await createFile({
|
|
752
|
+
kind: "doc",
|
|
753
|
+
title: flags.title,
|
|
754
|
+
body: flags.body,
|
|
755
|
+
});
|
|
756
|
+
return ok(fileLine(j));
|
|
757
|
+
}
|
|
758
|
+
if ((verb === "slide" || verb === "slides") && noun === "create") {
|
|
759
|
+
const j = await createFile({ kind: "slide", title: flags.title });
|
|
760
|
+
return ok(fileLine(j));
|
|
761
|
+
}
|
|
762
|
+
if ((verb === "meeting" || verb === "event") && noun === "create") {
|
|
763
|
+
return await meetingCreate({
|
|
764
|
+
title: flags.title,
|
|
765
|
+
start: flags.start,
|
|
766
|
+
end: flags.end,
|
|
767
|
+
when: flags.when,
|
|
768
|
+
attendees: flags.attendees || flags.to,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
return fail(
|
|
772
|
+
"usage: google-workspace [--account NAME] whoami | scopes | accounts | login | sheet create --title T [--csv csv] | sheet set --id ID --cell A1 --value V | sheet get --id ID [--range A1:B2] | sheet append --id ID --csv csv | sheet clear --id ID [--range A1:Z] | doc create --title T [--body text] | doc append --id ID --body text | drive list [--query Q] | slide create --title T | meeting create --title T [--start ISO --end ISO] [--attendees a@b,c@d] | mail send --to E --subject S --body T | mail list",
|
|
773
|
+
);
|
|
774
|
+
} catch (e) {
|
|
775
|
+
return fail(errMessage(e));
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
export { parseArgv, createFile, meetingCreate, whoami, main };
|