codeply-cli 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/THIRD_PARTY_NOTICES.md +65 -63
- package/bin/codeply.js +1 -1
- package/lib/agent.mjs +133 -24
- package/lib/ai.js +268 -9
- package/lib/ai.mjs +6 -6
- package/lib/apply-patch.js +217 -217
- package/lib/arity-table.json +138 -138
- package/lib/arity.js +70 -70
- package/lib/auth.mjs +6 -6
- package/lib/bots.js +493 -16
- package/lib/chatgpt.js +282 -0
- package/lib/cloud-browser.mjs +143 -143
- package/lib/cloud.mjs +1186 -1105
- package/lib/commands.js +103 -103
- package/lib/config.js +83 -3
- package/lib/design-library/assemble-artifact.mjs +21 -21
- package/lib/design-library/cleanup.mjs +45 -45
- package/lib/design-library/index.json +22379 -22379
- package/lib/design-library/make-table-data.mjs +39 -39
- package/lib/diagnostics.js +192 -192
- package/lib/edit-engine.js +364 -364
- package/lib/github-agent.mjs +311 -311
- package/lib/history.js +75 -75
- package/lib/mail-watch.js +504 -0
- package/lib/mcp.js +258 -258
- package/lib/model-sync.js +114 -0
- package/lib/native-tools.mjs +16 -1
- package/lib/oauth-connectors.js +146 -24
- package/lib/permissions.js +109 -109
- package/lib/plugins.js +353 -353
- package/lib/publish.js +916 -0
- package/lib/reach.js +67 -0
- package/lib/server.mjs +457 -457
- package/lib/session-db.js +220 -220
- package/lib/share.js +168 -168
- package/lib/skills.js +2 -0
- package/lib/snapshot.js +146 -146
- package/lib/telemetry.js +102 -0
- package/lib/terminal-hints.js +154 -154
- package/lib/tools.mjs +316 -14
- package/lib/typecheck-worker.js +147 -147
- package/lib/web-tools.js +209 -158
- package/package.json +49 -49
- package/skills/SOURCE.md +58 -58
- package/skills/accessibility/SKILL.md +147 -147
- package/skills/agent-architecture-audit/SKILL.md +257 -257
- package/skills/agent-eval/SKILL.md +147 -147
- package/skills/agent-harness-construction/SKILL.md +74 -74
- package/skills/agent-introspection-debugging/SKILL.md +154 -154
- package/skills/agent-payment-x402/SKILL.md +225 -225
- package/skills/agent-self-evaluation/SKILL.md +182 -182
- package/skills/agent-self-evaluation/references/evaluation-criteria.md +71 -71
- package/skills/agent-self-evaluation/references/hook-integration.md +64 -64
- package/skills/agent-sort/SKILL.md +216 -216
- package/skills/agentic-engineering/SKILL.md +64 -64
- package/skills/agentic-os/SKILL.md +388 -388
- package/skills/ai-first-engineering/SKILL.md +52 -52
- package/skills/ai-regression-testing/SKILL.md +386 -386
- package/skills/android-clean-architecture/SKILL.md +340 -340
- package/skills/angular-developer/SKILL.md +155 -155
- package/skills/angular-developer/references/angular-animations.md +160 -160
- package/skills/angular-developer/references/angular-aria.md +410 -410
- package/skills/angular-developer/references/cli.md +86 -86
- package/skills/angular-developer/references/component-harnesses.md +59 -59
- package/skills/angular-developer/references/component-styling.md +91 -91
- package/skills/angular-developer/references/components.md +117 -117
- package/skills/angular-developer/references/creating-services.md +97 -97
- package/skills/angular-developer/references/data-resolvers.md +69 -69
- package/skills/angular-developer/references/define-routes.md +67 -67
- package/skills/angular-developer/references/defining-providers.md +72 -72
- package/skills/angular-developer/references/di-fundamentals.md +120 -120
- package/skills/angular-developer/references/e2e-testing.md +56 -56
- package/skills/angular-developer/references/effects.md +83 -83
- package/skills/angular-developer/references/hierarchical-injectors.md +43 -43
- package/skills/angular-developer/references/host-elements.md +80 -80
- package/skills/angular-developer/references/injection-context.md +63 -63
- package/skills/angular-developer/references/inputs.md +101 -101
- package/skills/angular-developer/references/linked-signal.md +59 -59
- package/skills/angular-developer/references/loading-strategies.md +61 -61
- package/skills/angular-developer/references/mcp.md +108 -108
- package/skills/angular-developer/references/navigate-to-routes.md +69 -69
- package/skills/angular-developer/references/outputs.md +86 -86
- package/skills/angular-developer/references/reactive-forms.md +122 -122
- package/skills/angular-developer/references/rendering-strategies.md +44 -44
- package/skills/angular-developer/references/resource.md +77 -77
- package/skills/angular-developer/references/route-animations.md +56 -56
- package/skills/angular-developer/references/route-guards.md +52 -52
- package/skills/angular-developer/references/router-lifecycle.md +45 -45
- package/skills/angular-developer/references/router-testing.md +87 -87
- package/skills/angular-developer/references/show-routes-with-outlets.md +68 -68
- package/skills/angular-developer/references/signal-forms.md +795 -795
- package/skills/angular-developer/references/signals-overview.md +94 -94
- package/skills/angular-developer/references/tailwind-css.md +69 -69
- package/skills/angular-developer/references/template-driven-forms.md +114 -114
- package/skills/angular-developer/references/testing-fundamentals.md +65 -65
- package/skills/api-connector-builder/SKILL.md +121 -121
- package/skills/api-design/SKILL.md +524 -524
- package/skills/architecture-decision-records/SKILL.md +180 -180
- package/skills/article-writing/SKILL.md +80 -80
- package/skills/automation-audit-ops/SKILL.md +143 -143
- package/skills/autonomous-agent-harness/SKILL.md +274 -274
- package/skills/autonomous-loops/SKILL.md +611 -611
- package/skills/backend-patterns/SKILL.md +562 -562
- package/skills/benchmark/SKILL.md +95 -95
- package/skills/benchmark-methodology/SKILL.md +191 -191
- package/skills/benchmark-optimization-loop/SKILL.md +71 -71
- package/skills/blender-motion-state-inspection/SKILL.md +165 -165
- package/skills/blueprint/SKILL.md +106 -106
- package/skills/brand-discovery/SKILL.md +145 -145
- package/skills/brand-discovery/references/10_purpose-why.md +40 -40
- package/skills/brand-discovery/references/20_positioning.md +44 -44
- package/skills/brand-discovery/references/30_audience-niche.md +52 -52
- package/skills/brand-discovery/references/40_personality-archetype.md +57 -57
- package/skills/brand-discovery/references/50_voice-tone.md +59 -59
- package/skills/brand-discovery/references/60_narrative-story.md +50 -50
- package/skills/brand-discovery/references/70_founder-tension.md +49 -49
- package/skills/brand-discovery/references/90_SYNTHESIS.md +133 -133
- package/skills/brand-voice/SKILL.md +98 -98
- package/skills/brand-voice/references/voice-profile-schema.md +55 -55
- package/skills/browser-qa/SKILL.md +105 -105
- package/skills/bun-runtime/SKILL.md +85 -85
- package/skills/canary-watch/SKILL.md +108 -108
- package/skills/carrier-relationship-management/SKILL.md +212 -212
- package/skills/cisco-ios-patterns/SKILL.md +164 -164
- package/skills/ck/SKILL.md +148 -148
- package/skills/claude-devfleet/SKILL.md +112 -112
- package/skills/click-path-audit/SKILL.md +245 -245
- package/skills/clickhouse-io/SKILL.md +445 -445
- package/skills/code-tour/SKILL.md +254 -254
- package/skills/codebase-onboarding/SKILL.md +234 -234
- package/skills/codehealth-mcp/SKILL.md +167 -167
- package/skills/coding-standards/SKILL.md +551 -551
- package/skills/competitive-platform-analysis/SKILL.md +214 -214
- package/skills/competitive-report-structure/SKILL.md +162 -162
- package/skills/compose-multiplatform-patterns/SKILL.md +300 -300
- package/skills/config-gc/SKILL.md +120 -120
- package/skills/configure-ecc/SKILL.md +385 -385
- package/skills/connections-optimizer/SKILL.md +190 -190
- package/skills/content-engine/SKILL.md +132 -132
- package/skills/content-hash-cache-pattern/SKILL.md +162 -162
- package/skills/context-budget/SKILL.md +136 -136
- package/skills/continuous-agent-loop/SKILL.md +46 -46
- package/skills/continuous-learning/SKILL.md +132 -132
- package/skills/continuous-learning-v2/SKILL.md +361 -361
- package/skills/contract-first/SKILL.md +287 -287
- package/skills/cost-aware-llm-pipeline/SKILL.md +184 -184
- package/skills/cost-tracking/SKILL.md +97 -97
- package/skills/council/SKILL.md +204 -204
- package/skills/cpp-coding-standards/SKILL.md +724 -724
- package/skills/cpp-testing/SKILL.md +325 -325
- package/skills/crosspost/SKILL.md +112 -112
- package/skills/csharp-testing/SKILL.md +322 -322
- package/skills/customer-billing-ops/SKILL.md +141 -141
- package/skills/customs-trade-compliance/SKILL.md +263 -263
- package/skills/dart-flutter-patterns/SKILL.md +564 -564
- package/skills/dashboard-builder/SKILL.md +109 -109
- package/skills/data-scraper-agent/SKILL.md +765 -765
- package/skills/data-throughput-accelerator/SKILL.md +74 -74
- package/skills/database-migrations/SKILL.md +430 -430
- package/skills/deep-research/SKILL.md +160 -160
- package/skills/defi-amm-security/SKILL.md +167 -167
- package/skills/delivery-gate/SKILL.md +126 -126
- package/skills/deployment-patterns/SKILL.md +428 -428
- package/skills/design-system/SKILL.md +83 -83
- package/skills/django-celery/SKILL.md +458 -458
- package/skills/django-patterns/SKILL.md +735 -735
- package/skills/django-security/SKILL.md +644 -644
- package/skills/django-tdd/SKILL.md +730 -730
- package/skills/django-verification/SKILL.md +470 -470
- package/skills/dmux-workflows/SKILL.md +192 -192
- package/skills/docker-patterns/SKILL.md +365 -365
- package/skills/documentation-lookup/SKILL.md +91 -91
- package/skills/dotnet-patterns/SKILL.md +322 -322
- package/skills/dynamic-workflow-mode/SKILL.md +124 -124
- package/skills/e2e-testing/SKILL.md +327 -327
- package/skills/ecc-guide/SKILL.md +190 -190
- package/skills/ecc-recipes/SKILL.md +149 -149
- package/skills/ecc-tools-cost-audit/SKILL.md +161 -161
- package/skills/email-ops/SKILL.md +122 -122
- package/skills/energy-procurement/SKILL.md +228 -228
- package/skills/enterprise-agent-ops/SKILL.md +51 -51
- package/skills/error-handling/SKILL.md +377 -377
- package/skills/eval-harness/SKILL.md +271 -271
- package/skills/evm-token-decimals/SKILL.md +131 -131
- package/skills/exa-search/SKILL.md +108 -108
- package/skills/fal-ai-media/SKILL.md +289 -289
- package/skills/fastapi-patterns/SKILL.md +514 -514
- package/skills/finance-billing-ops/SKILL.md +128 -128
- package/skills/flox-environments/SKILL.md +497 -497
- package/skills/flutter-dart-code-review/SKILL.md +436 -436
- package/skills/foundation-models-on-device/SKILL.md +243 -243
- package/skills/frontend-a11y/SKILL.md +446 -446
- package/skills/frontend-design-direction/SKILL.md +93 -93
- package/skills/frontend-patterns/SKILL.md +657 -657
- package/skills/frontend-slides/SKILL.md +185 -185
- package/skills/fsharp-testing/SKILL.md +281 -281
- package/skills/gan-style-harness/SKILL.md +279 -279
- package/skills/gateguard/SKILL.md +133 -133
- package/skills/generating-python-installer/SKILL.md +820 -820
- package/skills/git-workflow/SKILL.md +716 -716
- package/skills/github-ops/SKILL.md +145 -145
- package/skills/golang-patterns/SKILL.md +676 -676
- package/skills/golang-testing/SKILL.md +721 -721
- package/skills/google-workspace-ops/SKILL.md +96 -96
- package/skills/growth-log/SKILL.md +128 -128
- package/skills/healthcare-cdss-patterns/SKILL.md +246 -246
- package/skills/healthcare-emr-patterns/SKILL.md +160 -160
- package/skills/healthcare-eval-harness/SKILL.md +208 -208
- package/skills/healthcare-phi-compliance/SKILL.md +146 -146
- package/skills/hermes-imports/SKILL.md +89 -89
- package/skills/hexagonal-architecture/SKILL.md +277 -277
- package/skills/hipaa-compliance/SKILL.md +79 -79
- package/skills/homelab-network-readiness/SKILL.md +170 -170
- package/skills/homelab-network-setup/SKILL.md +130 -130
- package/skills/homelab-pihole-dns/SKILL.md +275 -275
- package/skills/homelab-vlan-segmentation/SKILL.md +312 -312
- package/skills/homelab-wireguard-vpn/SKILL.md +306 -306
- package/skills/hookify-rules/SKILL.md +128 -128
- package/skills/inherit-legacy-style/SKILL.md +157 -157
- package/skills/intent-driven-development/SKILL.md +360 -360
- package/skills/inventory-demand-planning/SKILL.md +247 -247
- package/skills/investor-materials/SKILL.md +97 -97
- package/skills/investor-outreach/SKILL.md +92 -92
- package/skills/ios-icon-gen/SKILL.md +158 -158
- package/skills/iterative-retrieval/SKILL.md +212 -212
- package/skills/ito-basket-compare/SKILL.md +64 -64
- package/skills/ito-compute/SKILL.md +129 -129
- package/skills/ito-data-atlas-agent/SKILL.md +64 -64
- package/skills/ito-market-intelligence/SKILL.md +61 -61
- package/skills/ito-trade-planner/SKILL.md +68 -68
- package/skills/java-coding-standards/SKILL.md +384 -384
- package/skills/jira-integration/SKILL.md +303 -303
- package/skills/jpa-patterns/SKILL.md +152 -152
- package/skills/knowledge-ops/SKILL.md +155 -155
- package/skills/kotlin-coroutines-flows/SKILL.md +285 -285
- package/skills/kotlin-exposed-patterns/SKILL.md +720 -720
- package/skills/kotlin-ktor-patterns/SKILL.md +690 -690
- package/skills/kotlin-patterns/SKILL.md +712 -712
- package/skills/kotlin-testing/SKILL.md +825 -825
- package/skills/kubernetes-patterns/SKILL.md +756 -756
- package/skills/laravel-patterns/SKILL.md +416 -416
- package/skills/laravel-plugin-discovery/SKILL.md +230 -230
- package/skills/laravel-security/SKILL.md +948 -948
- package/skills/laravel-tdd/SKILL.md +675 -675
- package/skills/laravel-verification/SKILL.md +180 -180
- package/skills/latency-critical-systems/SKILL.md +75 -75
- package/skills/lead-intelligence/SKILL.md +322 -322
- package/skills/liquid-glass-design/SKILL.md +279 -279
- package/skills/llm-trading-agent-security/SKILL.md +147 -147
- package/skills/logistics-exception-management/SKILL.md +222 -222
- package/skills/loop-design-check/SKILL.md +143 -143
- package/skills/mailtrap-email-integration/SKILL.md +77 -77
- package/skills/make-interfaces-feel-better/SKILL.md +152 -152
- package/skills/manim-video/SKILL.md +90 -90
- package/skills/market-research/SKILL.md +76 -76
- package/skills/marketing-campaign/SKILL.md +114 -114
- package/skills/mcp-server-patterns/SKILL.md +70 -70
- package/skills/messages-ops/SKILL.md +105 -105
- package/skills/ml-adoption-playbook/SKILL.md +57 -57
- package/skills/mle-workflow/SKILL.md +348 -348
- package/skills/motion-advanced/SKILL.md +596 -596
- package/skills/motion-foundations/SKILL.md +299 -299
- package/skills/motion-patterns/SKILL.md +434 -434
- package/skills/motion-ui/SKILL.md +576 -576
- package/skills/mysql-patterns/SKILL.md +413 -413
- package/skills/nanoclaw-repl/SKILL.md +34 -34
- package/skills/nestjs-patterns/SKILL.md +231 -231
- package/skills/netmiko-ssh-automation/SKILL.md +174 -174
- package/skills/network-bgp-diagnostics/SKILL.md +168 -168
- package/skills/network-config-validation/SKILL.md +211 -211
- package/skills/network-interface-health/SKILL.md +153 -153
- package/skills/nextjs-turbopack/SKILL.md +58 -58
- package/skills/nodejs-keccak256/SKILL.md +103 -103
- package/skills/nutrient-document-processing/SKILL.md +168 -168
- package/skills/nuxt4-patterns/SKILL.md +101 -101
- package/skills/openclaw-persona-forge/SKILL.md +289 -289
- package/skills/openclaw-persona-forge/references/avatar-style.md +124 -124
- package/skills/openclaw-persona-forge/references/boundary-rules.md +53 -53
- package/skills/openclaw-persona-forge/references/error-handling.md +53 -53
- package/skills/openclaw-persona-forge/references/identity-tension.md +48 -48
- package/skills/openclaw-persona-forge/references/naming-system.md +39 -39
- package/skills/openclaw-persona-forge/references/output-template.md +166 -166
- package/skills/opensource-pipeline/SKILL.md +256 -256
- package/skills/orch-add-feature/SKILL.md +45 -45
- package/skills/orch-build-mvp/SKILL.md +49 -49
- package/skills/orch-change-feature/SKILL.md +43 -43
- package/skills/orch-fix-defect/SKILL.md +43 -43
- package/skills/orch-pipeline/SKILL.md +121 -121
- package/skills/orch-refine-code/SKILL.md +44 -44
- package/skills/parallel-execution-optimizer/SKILL.md +74 -74
- package/skills/perl-patterns/SKILL.md +505 -505
- package/skills/perl-security/SKILL.md +504 -504
- package/skills/perl-testing/SKILL.md +476 -476
- package/skills/plan-canvas/SKILL.md +153 -153
- package/skills/plan-orchestrate/SKILL.md +263 -263
- package/skills/plankton-code-quality/SKILL.md +237 -237
- package/skills/postgres-patterns/SKILL.md +148 -148
- package/skills/prediction-market-oracle-research/SKILL.md +64 -64
- package/skills/prediction-market-risk-review/SKILL.md +61 -61
- package/skills/premium-web-design/SKILL.md +154 -154
- package/skills/prisma-patterns/SKILL.md +401 -401
- package/skills/product-capability/SKILL.md +142 -142
- package/skills/product-lens/SKILL.md +93 -93
- package/skills/production-audit/SKILL.md +207 -207
- package/skills/production-scheduling/SKILL.md +238 -238
- package/skills/project-flow-ops/SKILL.md +112 -112
- package/skills/prompt-optimizer/SKILL.md +398 -398
- package/skills/publish-website/SKILL.md +76 -0
- package/skills/python-patterns/SKILL.md +751 -751
- package/skills/python-testing/SKILL.md +817 -817
- package/skills/pytorch-patterns/SKILL.md +397 -397
- package/skills/quality-nonconformance/SKILL.md +260 -260
- package/skills/quarkus-patterns/SKILL.md +723 -723
- package/skills/quarkus-security/SKILL.md +468 -468
- package/skills/quarkus-tdd/SKILL.md +812 -812
- package/skills/quarkus-verification/SKILL.md +480 -480
- package/skills/ralphinho-rfc-pipeline/SKILL.md +68 -68
- package/skills/react-native-patterns/SKILL.md +326 -326
- package/skills/react-patterns/SKILL.md +342 -342
- package/skills/react-performance/SKILL.md +575 -575
- package/skills/react-testing/SKILL.md +424 -424
- package/skills/recsys-pipeline-architect/SKILL.md +115 -115
- package/skills/recursive-decision-ledger/SKILL.md +81 -81
- package/skills/redis-patterns/SKILL.md +404 -404
- package/skills/regex-vs-llm-structured-text/SKILL.md +221 -221
- package/skills/remotion-video-creation/SKILL.md +43 -43
- package/skills/repo-scan/SKILL.md +79 -79
- package/skills/research-ops/SKILL.md +113 -113
- package/skills/returns-reverse-logistics/SKILL.md +240 -240
- package/skills/rules-distill/SKILL.md +265 -265
- package/skills/rust-patterns/SKILL.md +500 -500
- package/skills/rust-testing/SKILL.md +501 -501
- package/skills/safety-guard/SKILL.md +76 -76
- package/skills/santa-method/SKILL.md +307 -307
- package/skills/scientific-db-pubmed-database/SKILL.md +176 -176
- package/skills/scientific-db-uspto-database/SKILL.md +178 -178
- package/skills/scientific-pkg-gget/SKILL.md +167 -167
- package/skills/scientific-thinking-literature-review/SKILL.md +193 -193
- package/skills/scientific-thinking-scholar-evaluation/SKILL.md +161 -161
- package/skills/search-first/SKILL.md +183 -183
- package/skills/security-bounty-hunter/SKILL.md +100 -100
- package/skills/security-review/SKILL.md +504 -504
- package/skills/security-scan/SKILL.md +166 -166
- package/skills/seo/SKILL.md +155 -155
- package/skills/skill-comply/SKILL.md +59 -59
- package/skills/skill-scout/SKILL.md +141 -141
- package/skills/skill-stocktake/SKILL.md +195 -195
- package/skills/social-graph-ranker/SKILL.md +155 -155
- package/skills/social-publisher/SKILL.md +130 -130
- package/skills/springboot-patterns/SKILL.md +315 -315
- package/skills/springboot-security/SKILL.md +273 -273
- package/skills/springboot-tdd/SKILL.md +159 -159
- package/skills/springboot-verification/SKILL.md +232 -232
- package/skills/strategic-compact/SKILL.md +142 -142
- package/skills/swift-actor-persistence/SKILL.md +144 -144
- package/skills/swift-concurrency-6-2/SKILL.md +216 -216
- package/skills/swift-protocol-di-testing/SKILL.md +191 -191
- package/skills/swiftui-patterns/SKILL.md +259 -259
- package/skills/taste/SKILL.md +264 -264
- package/skills/taste/references/genre-taxonomy.md +87 -87
- package/skills/tdd-workflow/SKILL.md +583 -583
- package/skills/team-agent-orchestration/SKILL.md +111 -111
- package/skills/team-builder/SKILL.md +169 -169
- package/skills/terminal-ops/SKILL.md +110 -110
- package/skills/tinystruct-patterns/SKILL.md +279 -279
- package/skills/tinystruct-patterns/references/architecture.md +90 -90
- package/skills/tinystruct-patterns/references/data-handling.md +60 -60
- package/skills/tinystruct-patterns/references/database.md +99 -99
- package/skills/tinystruct-patterns/references/routing.md +64 -64
- package/skills/tinystruct-patterns/references/system-usage.md +97 -97
- package/skills/tinystruct-patterns/references/testing.md +72 -72
- package/skills/token-budget-advisor/SKILL.md +134 -134
- package/skills/ui-demo/SKILL.md +466 -466
- package/skills/ui-to-vue/SKILL.md +135 -135
- package/skills/uncloud/SKILL.md +344 -344
- package/skills/unified-memory/SKILL.md +170 -170
- package/skills/unified-notifications-ops/SKILL.md +188 -188
- package/skills/verification-loop/SKILL.md +129 -129
- package/skills/video-editing/SKILL.md +311 -311
- package/skills/videodb/SKILL.md +375 -375
- package/skills/visa-doc-translate/SKILL.md +117 -117
- package/skills/vite-patterns/SKILL.md +450 -450
- package/skills/vue-patterns/SKILL.md +471 -471
- package/skills/windows-desktop-e2e/SKILL.md +888 -888
- package/skills/workspace-surface-audit/SKILL.md +126 -126
- package/skills/x-api/SKILL.md +235 -235
package/lib/chatgpt.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign in with ChatGPT - lets someone run Craft on the models included in
|
|
3
|
+
* their own ChatGPT plan instead of an API key.
|
|
4
|
+
*
|
|
5
|
+
* This is OpenAI's official open-source / local-app flow
|
|
6
|
+
* (developers.openai.com/siwc/token-sharing-open-source): OAuth 2 with PKCE
|
|
7
|
+
* in the system browser, a loopback redirect on 127.0.0.1, and dynamic client
|
|
8
|
+
* registration - the first sign-in uses client_id "dynamic_agent_client" and
|
|
9
|
+
* the callback hands back a client_id issued for this user + this install.
|
|
10
|
+
* The resulting access token calls the public Responses API
|
|
11
|
+
* (api.openai.com/v1/responses), billed to the user's plan. ai.js does the
|
|
12
|
+
* calling; this file owns the tokens.
|
|
13
|
+
*
|
|
14
|
+
* Everything is stored in ~/.codeply/chatgpt.json (owner-only), never sent
|
|
15
|
+
* anywhere but auth.openai.com and api.openai.com.
|
|
16
|
+
*/
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const http = require('http');
|
|
21
|
+
const crypto = require('crypto');
|
|
22
|
+
|
|
23
|
+
const ISSUER = 'https://auth.openai.com';
|
|
24
|
+
const AUTHORIZE_URL = `${ISSUER}/api/accounts/authorize`;
|
|
25
|
+
const TOKEN_URL = `${ISSUER}/api/accounts/oauth/token`;
|
|
26
|
+
const RESOURCE = 'https://api.openai.com/v1';
|
|
27
|
+
const MODELS_URL = `${RESOURCE}/models`;
|
|
28
|
+
const SCOPE = 'openid profile email offline_access resource.invoke chatgpt.tokens.use.direct';
|
|
29
|
+
const PLAN_SCOPE = 'chatgpt.tokens.use.direct';
|
|
30
|
+
const DYNAMIC_CLIENT = 'dynamic_agent_client';
|
|
31
|
+
const APP_NAME = 'Codeply Craft';
|
|
32
|
+
const SIGN_IN_TIMEOUT_MS = 5 * 60_000;
|
|
33
|
+
const USAGE_URL = 'https://chatgpt.com/settings/usage';
|
|
34
|
+
|
|
35
|
+
const storePath = path.join(os.homedir(), '.codeply', 'chatgpt.json');
|
|
36
|
+
|
|
37
|
+
function readStore() {
|
|
38
|
+
try { return JSON.parse(fs.readFileSync(storePath, 'utf8')) || {}; } catch { return {}; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Atomic, owner-only write - the file holds a refresh token. */
|
|
42
|
+
function writeStore(data) {
|
|
43
|
+
fs.mkdirSync(path.dirname(storePath), { recursive: true });
|
|
44
|
+
const tmp = `${storePath}.${process.pid}.tmp`;
|
|
45
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
46
|
+
fs.renameSync(tmp, storePath);
|
|
47
|
+
try { fs.chmodSync(storePath, 0o600); } catch {}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The per-install host id OpenAI requires before the first sign-in. Opaque,
|
|
52
|
+
* random, never derived from anything about the user, and kept for the life
|
|
53
|
+
* of the install (sign-out keeps it, as the docs ask).
|
|
54
|
+
*/
|
|
55
|
+
function hostId() {
|
|
56
|
+
const store = readStore();
|
|
57
|
+
if (store.hostId) return store.hostId;
|
|
58
|
+
const id = `urn:uuid:${crypto.randomUUID()}`;
|
|
59
|
+
writeStore({ ...store, hostId: id });
|
|
60
|
+
return id;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
64
|
+
const randomToken = () => b64url(crypto.randomBytes(32));
|
|
65
|
+
|
|
66
|
+
function decodeJwt(token) {
|
|
67
|
+
const [h, p, s] = String(token || '').split('.');
|
|
68
|
+
if (!h || !p || !s) throw new Error('Malformed token.');
|
|
69
|
+
return {
|
|
70
|
+
header: JSON.parse(Buffer.from(h, 'base64url').toString('utf8')),
|
|
71
|
+
payload: JSON.parse(Buffer.from(p, 'base64url').toString('utf8')),
|
|
72
|
+
signingInput: `${h}.${p}`,
|
|
73
|
+
signature: Buffer.from(s, 'base64url'),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let jwksCache = null;
|
|
78
|
+
async function signingKey(kid) {
|
|
79
|
+
if (!jwksCache || !jwksCache.keys.some((k) => k.kid === kid)) {
|
|
80
|
+
const disco = await (await fetch(`${ISSUER}/.well-known/openid-configuration`)).json();
|
|
81
|
+
jwksCache = await (await fetch(disco.jwks_uri)).json();
|
|
82
|
+
}
|
|
83
|
+
const jwk = jwksCache.keys.find((k) => k.kid === kid);
|
|
84
|
+
if (!jwk) throw new Error('ChatGPT sign-in returned a token signed with an unknown key.');
|
|
85
|
+
return crypto.createPublicKey({ key: jwk, format: 'jwk' });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Signature, issuer, audience, nonce and expiry - all required by the docs before trusting the profile. */
|
|
89
|
+
async function verifyIdToken(idToken, { clientId, nonce }) {
|
|
90
|
+
const { header, payload, signingInput, signature } = decodeJwt(idToken);
|
|
91
|
+
const algs = { RS256: 'sha256', RS384: 'sha384', RS512: 'sha512' };
|
|
92
|
+
if (!algs[header.alg]) throw new Error(`Unsupported ID token algorithm ${header.alg}.`);
|
|
93
|
+
const ok = crypto.verify(algs[header.alg], Buffer.from(signingInput), await signingKey(header.kid), signature);
|
|
94
|
+
if (!ok) throw new Error('ChatGPT sign-in returned an ID token with a bad signature.');
|
|
95
|
+
const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
96
|
+
if (payload.iss !== ISSUER) throw new Error('ChatGPT sign-in returned a token from the wrong issuer.');
|
|
97
|
+
if (!aud.includes(clientId)) throw new Error('ChatGPT sign-in returned a token for a different app.');
|
|
98
|
+
if (nonce && payload.nonce !== nonce) throw new Error('ChatGPT sign-in did not match this attempt. Try again.');
|
|
99
|
+
if (payload.exp && payload.exp * 1000 < Date.now() - 60_000) throw new Error('ChatGPT sign-in returned an expired token.');
|
|
100
|
+
return payload;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function tokenRequest(params) {
|
|
104
|
+
const res = await fetch(TOKEN_URL, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
107
|
+
body: new URLSearchParams(params).toString(),
|
|
108
|
+
});
|
|
109
|
+
const body = await res.json().catch(() => ({}));
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
const err = new Error(body.error_description || body.error || `ChatGPT sign-in failed (HTTP ${res.status}).`);
|
|
112
|
+
err.code = body.error;
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
return body;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const PAGE = (title, text) => `<!doctype html><meta charset="utf-8"><title>${title}</title>
|
|
119
|
+
<body style="font-family:system-ui,sans-serif;background:#1b1b1b;color:#ececec;display:grid;place-items:center;height:100vh;margin:0">
|
|
120
|
+
<div style="text-align:center;max-width:360px"><h2 style="font-weight:600">${title}</h2><p style="color:#a8a8a8">${text}</p></div>`;
|
|
121
|
+
|
|
122
|
+
/** One-shot loopback server on a free port; resolves with the callback's query params. */
|
|
123
|
+
function listenForCallback(expectedState) {
|
|
124
|
+
let finish;
|
|
125
|
+
const result = new Promise((resolve, reject) => { finish = { resolve, reject }; });
|
|
126
|
+
result.catch(() => {}); // awaited by signIn; this only keeps an early reject from going unhandled
|
|
127
|
+
const server = http.createServer((req, res) => {
|
|
128
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
129
|
+
if (url.pathname !== '/callback') { res.writeHead(404); res.end(); return; }
|
|
130
|
+
const q = Object.fromEntries(url.searchParams);
|
|
131
|
+
const failed = q.error || !q.code || q.state !== expectedState;
|
|
132
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
133
|
+
res.end(failed
|
|
134
|
+
? PAGE('Sign-in did not finish', 'Go back to Codeply Craft and try again.')
|
|
135
|
+
: PAGE('You are signed in', 'You can close this tab and go back to Codeply Craft.'));
|
|
136
|
+
if (q.error) finish.reject(new Error(q.error_description || `ChatGPT sign-in was cancelled (${q.error}).`));
|
|
137
|
+
else if (q.state !== expectedState) finish.reject(new Error('ChatGPT sign-in did not match this attempt. Try again.'));
|
|
138
|
+
else if (!q.code) finish.reject(new Error('ChatGPT sign-in returned no authorization code.'));
|
|
139
|
+
else finish.resolve(q);
|
|
140
|
+
});
|
|
141
|
+
const ready = new Promise((resolve, reject) => {
|
|
142
|
+
server.once('error', reject);
|
|
143
|
+
server.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${server.address().port}/callback`));
|
|
144
|
+
});
|
|
145
|
+
const timer = setTimeout(() => finish.reject(new Error('ChatGPT sign-in timed out. Try again.')), SIGN_IN_TIMEOUT_MS);
|
|
146
|
+
const close = () => { clearTimeout(timer); server.close(); };
|
|
147
|
+
return { ready, result, close };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Full sign-in: opens the browser, waits for the redirect, exchanges the code,
|
|
152
|
+
* verifies the ID token and saves the profile. Reuses the client_id issued on
|
|
153
|
+
* an earlier sign-in (the docs: never reuse dynamic_agent_client).
|
|
154
|
+
*/
|
|
155
|
+
async function signIn({ openBrowser }) {
|
|
156
|
+
const store = readStore();
|
|
157
|
+
const verifier = randomToken();
|
|
158
|
+
const state = randomToken();
|
|
159
|
+
const nonce = randomToken();
|
|
160
|
+
const listener = listenForCallback(state);
|
|
161
|
+
try {
|
|
162
|
+
const redirectUri = await listener.ready;
|
|
163
|
+
const params = new URLSearchParams({
|
|
164
|
+
client_id: store.clientId || DYNAMIC_CLIENT,
|
|
165
|
+
ext_agent_host_id: hostId(),
|
|
166
|
+
response_type: 'code',
|
|
167
|
+
redirect_uri: redirectUri,
|
|
168
|
+
scope: SCOPE,
|
|
169
|
+
resource: RESOURCE,
|
|
170
|
+
state,
|
|
171
|
+
nonce,
|
|
172
|
+
code_challenge_method: 'S256',
|
|
173
|
+
code_challenge: b64url(crypto.createHash('sha256').update(verifier).digest()),
|
|
174
|
+
});
|
|
175
|
+
if (!store.clientId) params.set('agent_name_hint', APP_NAME);
|
|
176
|
+
if (store.email) params.set('login_hint', store.email);
|
|
177
|
+
await openBrowser(`${AUTHORIZE_URL}?${params}`);
|
|
178
|
+
|
|
179
|
+
const callback = await listener.result;
|
|
180
|
+
const clientId = callback.client_id || store.clientId;
|
|
181
|
+
if (!clientId || clientId === DYNAMIC_CLIENT) throw new Error('ChatGPT sign-in did not register this app. Try again.');
|
|
182
|
+
|
|
183
|
+
const tokens = await tokenRequest({
|
|
184
|
+
grant_type: 'authorization_code',
|
|
185
|
+
client_id: clientId,
|
|
186
|
+
code: callback.code,
|
|
187
|
+
code_verifier: verifier,
|
|
188
|
+
redirect_uri: redirectUri,
|
|
189
|
+
resource: RESOURCE,
|
|
190
|
+
});
|
|
191
|
+
const claims = await verifyIdToken(tokens.id_token, { clientId, nonce });
|
|
192
|
+
const scopes = String(tokens.scope || callback.scope || '').split(/\s+/).filter(Boolean);
|
|
193
|
+
writeStore({
|
|
194
|
+
...readStore(),
|
|
195
|
+
clientId,
|
|
196
|
+
subject: claims.sub,
|
|
197
|
+
email: claims.email || '',
|
|
198
|
+
name: claims.name || '',
|
|
199
|
+
idToken: tokens.id_token,
|
|
200
|
+
accessToken: tokens.access_token,
|
|
201
|
+
refreshToken: tokens.refresh_token,
|
|
202
|
+
expiresAt: Date.now() + (tokens.expires_in || 3600) * 1000,
|
|
203
|
+
scopes,
|
|
204
|
+
});
|
|
205
|
+
return status();
|
|
206
|
+
} finally {
|
|
207
|
+
listener.close();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Sign-out drops the tokens but keeps the host id and issued client_id for the next sign-in. */
|
|
212
|
+
function signOut() {
|
|
213
|
+
const { hostId: h, clientId, email } = readStore();
|
|
214
|
+
writeStore({ hostId: h, clientId, email });
|
|
215
|
+
return status();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function status() {
|
|
219
|
+
const s = readStore();
|
|
220
|
+
const signedIn = !!(s.refreshToken || s.accessToken);
|
|
221
|
+
return {
|
|
222
|
+
signedIn,
|
|
223
|
+
email: signedIn ? s.email || '' : '',
|
|
224
|
+
name: signedIn ? s.name || '' : '',
|
|
225
|
+
// Identity alone is not permission to use the plan - the docs make the
|
|
226
|
+
// plan scope its own grant the user can turn down.
|
|
227
|
+
sharing: signedIn && (s.scopes || []).includes(PLAN_SCOPE),
|
|
228
|
+
usageUrl: USAGE_URL,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const REFRESH_DEAD = new Set(['invalid_grant', 'invalid_refresh_token', 'token_expired', 'refresh_token_expired', 'refresh_token_invalidated', 'refresh_token_reused']);
|
|
233
|
+
|
|
234
|
+
let refreshing = null;
|
|
235
|
+
/** A valid access token, refreshed a minute before it expires. Throws a user-facing message when signed out. */
|
|
236
|
+
async function accessToken({ force = false } = {}) {
|
|
237
|
+
const s = readStore();
|
|
238
|
+
if (!s.refreshToken && !s.accessToken) throw new Error('Sign in with ChatGPT again from the model menu to use this model.');
|
|
239
|
+
if (!force && s.accessToken && s.expiresAt > Date.now() + 60_000) return s.accessToken;
|
|
240
|
+
if (!s.refreshToken) throw new Error('Your ChatGPT sign-in expired. Sign in again from the model menu.');
|
|
241
|
+
refreshing = refreshing || (async () => {
|
|
242
|
+
try {
|
|
243
|
+
const tokens = await tokenRequest({
|
|
244
|
+
grant_type: 'refresh_token',
|
|
245
|
+
client_id: s.clientId,
|
|
246
|
+
refresh_token: s.refreshToken,
|
|
247
|
+
resource: RESOURCE,
|
|
248
|
+
});
|
|
249
|
+
writeStore({
|
|
250
|
+
...readStore(),
|
|
251
|
+
accessToken: tokens.access_token,
|
|
252
|
+
refreshToken: tokens.refresh_token || s.refreshToken,
|
|
253
|
+
expiresAt: Date.now() + (tokens.expires_in || 3600) * 1000,
|
|
254
|
+
...(tokens.scope ? { scopes: String(tokens.scope).split(/\s+/).filter(Boolean) } : {}),
|
|
255
|
+
});
|
|
256
|
+
return tokens.access_token;
|
|
257
|
+
} catch (e) {
|
|
258
|
+
if (REFRESH_DEAD.has(e.code)) {
|
|
259
|
+
signOut();
|
|
260
|
+
throw new Error('Your ChatGPT sign-in expired. Sign in again from the model menu.');
|
|
261
|
+
}
|
|
262
|
+
throw e;
|
|
263
|
+
} finally {
|
|
264
|
+
refreshing = null;
|
|
265
|
+
}
|
|
266
|
+
})();
|
|
267
|
+
return refreshing;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The models this user's plan can use, as [{ slug, name }]. */
|
|
271
|
+
async function listModels() {
|
|
272
|
+
const res = await fetch(MODELS_URL, { headers: { Authorization: `Bearer ${await accessToken()}` } });
|
|
273
|
+
const body = await res.json().catch(() => ({}));
|
|
274
|
+
if (!res.ok) throw new Error(body?.error?.message || `Couldn't list your ChatGPT models (HTTP ${res.status}).`);
|
|
275
|
+
const list = body.models || body.data || [];
|
|
276
|
+
return list
|
|
277
|
+
.filter((m) => !m.visibility || m.visibility === 'list')
|
|
278
|
+
.map((m) => ({ slug: m.slug || m.id, name: m.display_name || m.slug || m.id }))
|
|
279
|
+
.filter((m) => m.slug);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
module.exports = { signIn, signOut, status, accessToken, listModels, USAGE_URL, RESOURCE };
|
package/lib/cloud-browser.mjs
CHANGED
|
@@ -1,143 +1,143 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A real browser for the cloud runner: headless Chrome (preinstalled on
|
|
3
|
-
* GitHub's ubuntu runners), driven over the DevTools protocol with Node's own
|
|
4
|
-
* WebSocket, so browser_check works in the cloud the way it does in the app:
|
|
5
|
-
* console errors, failed requests, broken images, sideways scroll, and a
|
|
6
|
-
* screenshot the model looks at and the chat shows.
|
|
7
|
-
*/
|
|
8
|
-
import fs from 'fs';
|
|
9
|
-
import os from 'os';
|
|
10
|
-
import path from 'path';
|
|
11
|
-
import { spawn } from 'child_process';
|
|
12
|
-
|
|
13
|
-
const CANDIDATES = [
|
|
14
|
-
process.env.CHROME_PATH,
|
|
15
|
-
'/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser',
|
|
16
|
-
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
17
|
-
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
18
|
-
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
19
|
-
].filter(Boolean);
|
|
20
|
-
|
|
21
|
-
const VIEWPORTS = { desktop: { width: 1280, height: 800, mobile: false }, tablet: { width: 768, height: 1024, mobile: true }, mobile: { width: 390, height: 844, mobile: true } };
|
|
22
|
-
const MOBILE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1';
|
|
23
|
-
|
|
24
|
-
export function findChrome() {
|
|
25
|
-
return CANDIDATES.find((p) => { try { return fs.statSync(p).isFile(); } catch { return false; } }) || null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function viewportFor(v) {
|
|
29
|
-
const key = String(v || 'desktop').toLowerCase();
|
|
30
|
-
if (VIEWPORTS[key]) return { name: key, ...VIEWPORTS[key] };
|
|
31
|
-
const m = /^(\d{3,4})\s*[x×]\s*(\d{3,4})$/.exec(key);
|
|
32
|
-
if (m) return { name: `${m[1]}x${m[2]}`, width: Number(m[1]), height: Number(m[2]), mobile: Number(m[1]) < 1024 };
|
|
33
|
-
return { name: 'desktop', ...VIEWPORTS.desktop };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** One DevTools connection: send(method, params) and on(event, fn). */
|
|
37
|
-
function cdp(wsUrl) {
|
|
38
|
-
return new Promise((resolve, reject) => {
|
|
39
|
-
const ws = new WebSocket(wsUrl);
|
|
40
|
-
let id = 0;
|
|
41
|
-
const pending = new Map();
|
|
42
|
-
const handlers = new Map();
|
|
43
|
-
ws.onopen = () => resolve({
|
|
44
|
-
send: (method, params = {}, sessionId) => new Promise((res, rej) => {
|
|
45
|
-
const msgId = ++id;
|
|
46
|
-
pending.set(msgId, { res, rej });
|
|
47
|
-
ws.send(JSON.stringify({ id: msgId, method, params, ...(sessionId ? { sessionId } : {}) }));
|
|
48
|
-
}),
|
|
49
|
-
on: (event, fn) => handlers.set(event, [...(handlers.get(event) || []), fn]),
|
|
50
|
-
close: () => { try { ws.close(); } catch {} },
|
|
51
|
-
});
|
|
52
|
-
ws.onerror = () => reject(new Error('Could not talk to Chrome.'));
|
|
53
|
-
ws.onmessage = (m) => {
|
|
54
|
-
const msg = JSON.parse(typeof m.data === 'string' ? m.data : Buffer.from(m.data).toString());
|
|
55
|
-
if (msg.id && pending.has(msg.id)) {
|
|
56
|
-
const p = pending.get(msg.id); pending.delete(msg.id);
|
|
57
|
-
if (msg.error) p.rej(new Error(msg.error.message)); else p.res(msg.result);
|
|
58
|
-
} else if (msg.method) for (const fn of handlers.get(msg.method) || []) fn(msg.params, msg.sessionId);
|
|
59
|
-
};
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* makeCloudBrowser() returns a browser(url, { wait, viewport }) function for
|
|
65
|
-
* the agent, or null when no Chrome is installed. Each check gets a fresh tab;
|
|
66
|
-
* Chrome itself starts once and is closed by close().
|
|
67
|
-
*/
|
|
68
|
-
export function makeCloudBrowser({ chromePath = findChrome(), shotsDir = path.join(os.tmpdir(), 'craft-shots') } = {}) {
|
|
69
|
-
if (!chromePath) return null;
|
|
70
|
-
let started = null;
|
|
71
|
-
let shot = 0;
|
|
72
|
-
const start = () => {
|
|
73
|
-
if (started) return started;
|
|
74
|
-
started = (async () => {
|
|
75
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'craft-chrome-'));
|
|
76
|
-
const proc = spawn(chromePath, ['--headless=new', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--hide-scrollbars',
|
|
77
|
-
'--remote-debugging-port=0', `--user-data-dir=${dir}`, 'about:blank'], { stdio: 'ignore' });
|
|
78
|
-
const portFile = path.join(dir, 'DevToolsActivePort');
|
|
79
|
-
for (let i = 0; i < 100 && !fs.existsSync(portFile); i++) await new Promise((r) => setTimeout(r, 100));
|
|
80
|
-
if (!fs.existsSync(portFile)) { proc.kill(); throw new Error('Chrome did not start.'); }
|
|
81
|
-
const [port, wsPath] = fs.readFileSync(portFile, 'utf8').trim().split('\n');
|
|
82
|
-
const conn = await cdp(`ws://127.0.0.1:${port}${wsPath}`);
|
|
83
|
-
return { proc, conn, dir };
|
|
84
|
-
})();
|
|
85
|
-
return started;
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
const browser = async (url, { wait = 1200, viewport } = {}) => {
|
|
89
|
-
const { conn } = await start();
|
|
90
|
-
const vp = viewportFor(viewport);
|
|
91
|
-
const { targetId } = await conn.send('Target.createTarget', { url: 'about:blank' });
|
|
92
|
-
const { sessionId } = await conn.send('Target.attachToTarget', { targetId, flatten: true });
|
|
93
|
-
const s = (m, p) => conn.send(m, p, sessionId);
|
|
94
|
-
const consoleErrors = []; const consoleWarnings = []; const failedRequests = [];
|
|
95
|
-
conn.on('Runtime.consoleAPICalled', (p, sid) => {
|
|
96
|
-
if (sid !== sessionId) return;
|
|
97
|
-
const text = (p.args || []).map((a) => a.value ?? a.description ?? '').join(' ').slice(0, 300);
|
|
98
|
-
if (p.type === 'error') consoleErrors.push(text); else if (p.type === 'warning') consoleWarnings.push(text);
|
|
99
|
-
});
|
|
100
|
-
conn.on('Runtime.exceptionThrown', (p, sid) => { if (sid === sessionId) consoleErrors.push(String((p.exceptionDetails.exception && p.exceptionDetails.exception.description) || p.exceptionDetails.text).split('\n')[0].slice(0, 300)); });
|
|
101
|
-
conn.on('Network.responseReceived', (p, sid) => { if (sid === sessionId && p.response.status >= 400) failedRequests.push(`HTTP ${p.response.status} - ${p.response.url}`); });
|
|
102
|
-
conn.on('Network.loadingFailed', (p, sid) => { if (sid === sessionId && !p.canceled) failedRequests.push(`${p.errorText} - request ${p.requestId}`); });
|
|
103
|
-
try {
|
|
104
|
-
await s('Page.enable'); await s('Runtime.enable'); await s('Network.enable');
|
|
105
|
-
await s('Emulation.setDeviceMetricsOverride', { width: vp.width, height: vp.height, deviceScaleFactor: 1, mobile: vp.mobile });
|
|
106
|
-
if (vp.mobile) await s('Emulation.setUserAgentOverride', { userAgent: MOBILE_UA });
|
|
107
|
-
const loaded = new Promise((r) => { conn.on('Page.loadEventFired', (p, sid) => { if (sid === sessionId) r(); }); setTimeout(r, 15000); });
|
|
108
|
-
const nav = await s('Page.navigate', { url });
|
|
109
|
-
if (nav.errorText) return { ok: false, error: nav.errorText };
|
|
110
|
-
await loaded;
|
|
111
|
-
await new Promise((r) => setTimeout(r, Math.min(8000, wait)));
|
|
112
|
-
const { result } = await s('Runtime.evaluate', {
|
|
113
|
-
returnByValue: true,
|
|
114
|
-
expression: `(() => {
|
|
115
|
-
const w = document.documentElement.scrollWidth, vw = window.innerWidth;
|
|
116
|
-
const wide = w > vw + 1 ? [...document.querySelectorAll('body *')].filter((e) => e.getBoundingClientRect().right > vw + 1).slice(0, 5).map((e) => e.tagName.toLowerCase() + (e.id ? '#' + e.id : e.className && typeof e.className === 'string' ? '.' + e.className.split(' ')[0] : '')) : [];
|
|
117
|
-
return { title: document.title, text: (document.body && document.body.innerText || '').slice(0, 3000),
|
|
118
|
-
brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0).map((i) => i.src).slice(0, 20),
|
|
119
|
-
pageWidth: w, viewportWidth: vw, overflowX: w > vw + 1, wideElements: wide,
|
|
120
|
-
hasViewportMeta: !!document.querySelector('meta[name="viewport"]') };
|
|
121
|
-
})()`,
|
|
122
|
-
});
|
|
123
|
-
const shotData = await s('Page.captureScreenshot', { format: 'png' });
|
|
124
|
-
fs.mkdirSync(shotsDir, { recursive: true });
|
|
125
|
-
const screenshotPath = path.join(shotsDir, `check-${++shot}.png`);
|
|
126
|
-
fs.writeFileSync(screenshotPath, Buffer.from(shotData.data, 'base64'));
|
|
127
|
-
return {
|
|
128
|
-
ok: true, ...result.value, viewport: `${vp.name} ${vp.width}x${vp.height}`,
|
|
129
|
-
consoleErrors, consoleWarnings, failedRequests,
|
|
130
|
-
screenshotPath, screenshotDataUrl: `data:image/png;base64,${shotData.data}`,
|
|
131
|
-
};
|
|
132
|
-
} catch (e) {
|
|
133
|
-
return { ok: false, error: e.message };
|
|
134
|
-
} finally {
|
|
135
|
-
conn.send('Target.closeTarget', { targetId }).catch(() => {});
|
|
136
|
-
}
|
|
137
|
-
};
|
|
138
|
-
browser.close = async () => {
|
|
139
|
-
if (!started) return;
|
|
140
|
-
try { const { proc, conn } = await started; conn.close(); proc.kill(); } catch {}
|
|
141
|
-
};
|
|
142
|
-
return browser;
|
|
143
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* A real browser for the cloud runner: headless Chrome (preinstalled on
|
|
3
|
+
* GitHub's ubuntu runners), driven over the DevTools protocol with Node's own
|
|
4
|
+
* WebSocket, so browser_check works in the cloud the way it does in the app:
|
|
5
|
+
* console errors, failed requests, broken images, sideways scroll, and a
|
|
6
|
+
* screenshot the model looks at and the chat shows.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { spawn } from 'child_process';
|
|
12
|
+
|
|
13
|
+
const CANDIDATES = [
|
|
14
|
+
process.env.CHROME_PATH,
|
|
15
|
+
'/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser',
|
|
16
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
17
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
18
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
19
|
+
].filter(Boolean);
|
|
20
|
+
|
|
21
|
+
const VIEWPORTS = { desktop: { width: 1280, height: 800, mobile: false }, tablet: { width: 768, height: 1024, mobile: true }, mobile: { width: 390, height: 844, mobile: true } };
|
|
22
|
+
const MOBILE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1';
|
|
23
|
+
|
|
24
|
+
export function findChrome() {
|
|
25
|
+
return CANDIDATES.find((p) => { try { return fs.statSync(p).isFile(); } catch { return false; } }) || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function viewportFor(v) {
|
|
29
|
+
const key = String(v || 'desktop').toLowerCase();
|
|
30
|
+
if (VIEWPORTS[key]) return { name: key, ...VIEWPORTS[key] };
|
|
31
|
+
const m = /^(\d{3,4})\s*[x×]\s*(\d{3,4})$/.exec(key);
|
|
32
|
+
if (m) return { name: `${m[1]}x${m[2]}`, width: Number(m[1]), height: Number(m[2]), mobile: Number(m[1]) < 1024 };
|
|
33
|
+
return { name: 'desktop', ...VIEWPORTS.desktop };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One DevTools connection: send(method, params) and on(event, fn). */
|
|
37
|
+
function cdp(wsUrl) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const ws = new WebSocket(wsUrl);
|
|
40
|
+
let id = 0;
|
|
41
|
+
const pending = new Map();
|
|
42
|
+
const handlers = new Map();
|
|
43
|
+
ws.onopen = () => resolve({
|
|
44
|
+
send: (method, params = {}, sessionId) => new Promise((res, rej) => {
|
|
45
|
+
const msgId = ++id;
|
|
46
|
+
pending.set(msgId, { res, rej });
|
|
47
|
+
ws.send(JSON.stringify({ id: msgId, method, params, ...(sessionId ? { sessionId } : {}) }));
|
|
48
|
+
}),
|
|
49
|
+
on: (event, fn) => handlers.set(event, [...(handlers.get(event) || []), fn]),
|
|
50
|
+
close: () => { try { ws.close(); } catch {} },
|
|
51
|
+
});
|
|
52
|
+
ws.onerror = () => reject(new Error('Could not talk to Chrome.'));
|
|
53
|
+
ws.onmessage = (m) => {
|
|
54
|
+
const msg = JSON.parse(typeof m.data === 'string' ? m.data : Buffer.from(m.data).toString());
|
|
55
|
+
if (msg.id && pending.has(msg.id)) {
|
|
56
|
+
const p = pending.get(msg.id); pending.delete(msg.id);
|
|
57
|
+
if (msg.error) p.rej(new Error(msg.error.message)); else p.res(msg.result);
|
|
58
|
+
} else if (msg.method) for (const fn of handlers.get(msg.method) || []) fn(msg.params, msg.sessionId);
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* makeCloudBrowser() returns a browser(url, { wait, viewport }) function for
|
|
65
|
+
* the agent, or null when no Chrome is installed. Each check gets a fresh tab;
|
|
66
|
+
* Chrome itself starts once and is closed by close().
|
|
67
|
+
*/
|
|
68
|
+
export function makeCloudBrowser({ chromePath = findChrome(), shotsDir = path.join(os.tmpdir(), 'craft-shots') } = {}) {
|
|
69
|
+
if (!chromePath) return null;
|
|
70
|
+
let started = null;
|
|
71
|
+
let shot = 0;
|
|
72
|
+
const start = () => {
|
|
73
|
+
if (started) return started;
|
|
74
|
+
started = (async () => {
|
|
75
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'craft-chrome-'));
|
|
76
|
+
const proc = spawn(chromePath, ['--headless=new', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--hide-scrollbars',
|
|
77
|
+
'--remote-debugging-port=0', `--user-data-dir=${dir}`, 'about:blank'], { stdio: 'ignore' });
|
|
78
|
+
const portFile = path.join(dir, 'DevToolsActivePort');
|
|
79
|
+
for (let i = 0; i < 100 && !fs.existsSync(portFile); i++) await new Promise((r) => setTimeout(r, 100));
|
|
80
|
+
if (!fs.existsSync(portFile)) { proc.kill(); throw new Error('Chrome did not start.'); }
|
|
81
|
+
const [port, wsPath] = fs.readFileSync(portFile, 'utf8').trim().split('\n');
|
|
82
|
+
const conn = await cdp(`ws://127.0.0.1:${port}${wsPath}`);
|
|
83
|
+
return { proc, conn, dir };
|
|
84
|
+
})();
|
|
85
|
+
return started;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const browser = async (url, { wait = 1200, viewport } = {}) => {
|
|
89
|
+
const { conn } = await start();
|
|
90
|
+
const vp = viewportFor(viewport);
|
|
91
|
+
const { targetId } = await conn.send('Target.createTarget', { url: 'about:blank' });
|
|
92
|
+
const { sessionId } = await conn.send('Target.attachToTarget', { targetId, flatten: true });
|
|
93
|
+
const s = (m, p) => conn.send(m, p, sessionId);
|
|
94
|
+
const consoleErrors = []; const consoleWarnings = []; const failedRequests = [];
|
|
95
|
+
conn.on('Runtime.consoleAPICalled', (p, sid) => {
|
|
96
|
+
if (sid !== sessionId) return;
|
|
97
|
+
const text = (p.args || []).map((a) => a.value ?? a.description ?? '').join(' ').slice(0, 300);
|
|
98
|
+
if (p.type === 'error') consoleErrors.push(text); else if (p.type === 'warning') consoleWarnings.push(text);
|
|
99
|
+
});
|
|
100
|
+
conn.on('Runtime.exceptionThrown', (p, sid) => { if (sid === sessionId) consoleErrors.push(String((p.exceptionDetails.exception && p.exceptionDetails.exception.description) || p.exceptionDetails.text).split('\n')[0].slice(0, 300)); });
|
|
101
|
+
conn.on('Network.responseReceived', (p, sid) => { if (sid === sessionId && p.response.status >= 400) failedRequests.push(`HTTP ${p.response.status} - ${p.response.url}`); });
|
|
102
|
+
conn.on('Network.loadingFailed', (p, sid) => { if (sid === sessionId && !p.canceled) failedRequests.push(`${p.errorText} - request ${p.requestId}`); });
|
|
103
|
+
try {
|
|
104
|
+
await s('Page.enable'); await s('Runtime.enable'); await s('Network.enable');
|
|
105
|
+
await s('Emulation.setDeviceMetricsOverride', { width: vp.width, height: vp.height, deviceScaleFactor: 1, mobile: vp.mobile });
|
|
106
|
+
if (vp.mobile) await s('Emulation.setUserAgentOverride', { userAgent: MOBILE_UA });
|
|
107
|
+
const loaded = new Promise((r) => { conn.on('Page.loadEventFired', (p, sid) => { if (sid === sessionId) r(); }); setTimeout(r, 15000); });
|
|
108
|
+
const nav = await s('Page.navigate', { url });
|
|
109
|
+
if (nav.errorText) return { ok: false, error: nav.errorText };
|
|
110
|
+
await loaded;
|
|
111
|
+
await new Promise((r) => setTimeout(r, Math.min(8000, wait)));
|
|
112
|
+
const { result } = await s('Runtime.evaluate', {
|
|
113
|
+
returnByValue: true,
|
|
114
|
+
expression: `(() => {
|
|
115
|
+
const w = document.documentElement.scrollWidth, vw = window.innerWidth;
|
|
116
|
+
const wide = w > vw + 1 ? [...document.querySelectorAll('body *')].filter((e) => e.getBoundingClientRect().right > vw + 1).slice(0, 5).map((e) => e.tagName.toLowerCase() + (e.id ? '#' + e.id : e.className && typeof e.className === 'string' ? '.' + e.className.split(' ')[0] : '')) : [];
|
|
117
|
+
return { title: document.title, text: (document.body && document.body.innerText || '').slice(0, 3000),
|
|
118
|
+
brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0).map((i) => i.src).slice(0, 20),
|
|
119
|
+
pageWidth: w, viewportWidth: vw, overflowX: w > vw + 1, wideElements: wide,
|
|
120
|
+
hasViewportMeta: !!document.querySelector('meta[name="viewport"]') };
|
|
121
|
+
})()`,
|
|
122
|
+
});
|
|
123
|
+
const shotData = await s('Page.captureScreenshot', { format: 'png' });
|
|
124
|
+
fs.mkdirSync(shotsDir, { recursive: true });
|
|
125
|
+
const screenshotPath = path.join(shotsDir, `check-${++shot}.png`);
|
|
126
|
+
fs.writeFileSync(screenshotPath, Buffer.from(shotData.data, 'base64'));
|
|
127
|
+
return {
|
|
128
|
+
ok: true, ...result.value, viewport: `${vp.name} ${vp.width}x${vp.height}`,
|
|
129
|
+
consoleErrors, consoleWarnings, failedRequests,
|
|
130
|
+
screenshotPath, screenshotDataUrl: `data:image/png;base64,${shotData.data}`,
|
|
131
|
+
};
|
|
132
|
+
} catch (e) {
|
|
133
|
+
return { ok: false, error: e.message };
|
|
134
|
+
} finally {
|
|
135
|
+
conn.send('Target.closeTarget', { targetId }).catch(() => {});
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
browser.close = async () => {
|
|
139
|
+
if (!started) return;
|
|
140
|
+
try { const { proc, conn } = await started; conn.close(); proc.kill(); } catch {}
|
|
141
|
+
};
|
|
142
|
+
return browser;
|
|
143
|
+
}
|