feat: SI CLI ↔ Console integration (auth, SaaS state, unified endpoints) - #211
Open
Blankll wants to merge 50 commits into
Open
feat: SI CLI ↔ Console integration (auth, SaaS state, unified endpoints)#211Blankll wants to merge 50 commits into
Blankll wants to merge 50 commits into
Conversation
Add StateBackendType.SAAS enum, SaasBackendConfig, console metadata fields to StateFile, default parseBackend to SAAS.
Add credentialStore for ~/.serverlessinsight/credentials.json and API client with ky-based HTTP client for Console communication.
Add SaasStateBackend implementing StateBackend interface via Console API. Uses unified POST /api/v1/deployments/ endpoint with auto-provisioning and phase-based lifecycle (init→start→complete/fail).
Add si login/logout/whoami commands with browser-based auth and API key input. Update deploy and show commands for SaaS state backend support. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Add SAAS_BACKEND_*, LOGIN_*, API_ERROR_* i18n keys for en/zh-CN. Add unit tests for credentialStore, apiClient, and saasStateBackend. Add ky HTTP client dependency.
- Unwrap { code, messages, data } envelope so callers consume payload directly
- Convert snake_case payload keys to camelCase (skip opaque JSONB fields like state_json/spec)
- validateApiKey reads org_id/org_name and nested user.email from the real validate response
Fixes: si login saved credentials without orgId (org/name/userEmail all undefined)
Ultraworked with Sisyphus
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
… lock read - Prefix all API paths with leading slash (baseUrl + path was malformed) - saveState sends full StateFile + SHA-256 contentHash (backend requires it) - readLock handles data:null when no deployment is active Fixes: si deploy could not provision/create deployments or sync state against the console Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…sole CLI cannot force-unlock SaaS-managed deployments (the API requires an admin JWT session, which the CLI never holds). Instead of attempting the request and failing with 'No lock found' / 4002, print a clear hint pointing to the console URL. - saasStateBackend.forceUnlock: throw SAAS_FORCE_UNLOCK_NOT_SUPPORTED instead of calling the API - forceUnlockCommand: detect SaaS backend (no backend config or type=saas) and print the hint before touching any lock file - i18n: en + zh-CN keys - tests: updated saas backend forceUnlock test; added SaaS-mode command test Verified: 2555 unit tests pass, lint clean, tsc clean. Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…ckend creation in force-unlock Two CI failures fixed: 1. PR #211 made the default state backend SaaS (parseBackend: no state_manager -> SAAS), breaking 20 service tests that expected local state files. Their fixtures now explicitly declare backend.state_manager.type=LOCAL, so they don't depend on the default. 2. forceUnlockCommand created the backend BEFORE checking for SaaS mode. createSaasStateBackend throws SAAS_BACKEND_NO_CREDENTIALS when no API key exists, so the web-console hint was unreachable in CI (no credentials). The SaaS check now runs before createStateBackend. Verified: 136 suites / 2577 tests pass, branches 86.04% (threshold 85%), lint clean. Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #211 +/- ##
==========================================
- Coverage 88.58% 88.43% -0.15%
==========================================
Files 148 155 +7
Lines 8540 9305 +765
Branches 2299 2499 +200
==========================================
+ Hits 7565 8229 +664
- Misses 421 442 +21
- Partials 554 634 +80 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…ows CI)
credentialStore.test.ts hardcoded POSIX paths ('/home/testuser/...'), which broke on windows-latest (backslash separators). Build expected paths with path.join so the assertions match on all platforms.
Ultraworked with Sisyphus
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…s patch coverage) Codecov reported 54.8% patch coverage — login.ts (87 lines), whoami.ts (14), logout.ts (11) had zero tests. Added: - login.test.ts (8 tests): API-key flow, env key, invalid format, validate failure, re-auth confirmation, decline, interactive prompt, and the full browser callback flow (real local HTTP server round-trip) - whoami.test.ts (3 tests): logged out, logged in, missing-field fallbacks - logout.test.ts (2 tests): logged out, credential deletion Also fixed a real bug in loginWithBrowser: the 5-minute timeout timer was never cleared after a successful callback, keeping the process alive for 5 minutes after login. clearTimeout now runs on callback. Verified: 2590 tests pass, branches 86.66%, login.ts coverage 0% -> 89.8%. Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
The real-HTTP version hung under coverage (open socket + 300s timeout timer). Mock node:http so the callback handler runs in-memory and the timeout timer is cleared on success, letting jest exit cleanly. Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
SEO: explicit Alibaba/Tencent/Huawei/Volcengine serverless CLI keywords in the README header, matching the repo topics. Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…gger Tencent SCF CreateFunction is async — the function stays in 'Creating' status until the platform finishes provisioning. The CLI immediately called CreateTrigger, which Tencent rejects with '当前函数状态无法进行此操作' (Status is Creating, unsupport operate), failing deploys of functions with http triggers (e.g. event-function-url probe). - scfResource: poll GetFunction until Status=Active (3s interval, 60s max) after createFunction, before trigger/domain creation - constants: SCF_STATUS_POLL_INTERVAL_MS / SCF_STATUS_POLL_MAX_ATTEMPTS - tests: +wait-for-active (Creating->Active sequence, trigger created only after), +timeout error when never Active; adapted existing mocks to include Status: 'Active' Verified: 481 scf/tencent tests pass, lint clean, tsc clean. Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Single generic wait implementation for async cloud resource creation (mirrors Terraform StateChangeConf / Tencent waitScfFunctionReady). - pollUntil<T>: fetch/isDone/intervalMs/maxAttempts/onProgress; fetch errors propagate; PollingTimeoutError carries last value - requiredConsecutiveHits: done state must be observed N consecutive times (anti stale-read for eventually-consistent APIs) - Tests: 12 cases (immediate, multi-poll, timeout, fetch-throw, null, onProgress, maxAttempts=1, consecutive hits) Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…update/delete waits) Architecture consistency: waits belong in the client operations layer (the established RDS/TDSQL/ES pattern), not the resource layer. - scfOperations: createFunction polls Status==='Active' before returning (pollUntil); waitForFunctionActive before update (update-before-active race); waitForFunctionDeleted after delete (delete-then-recreate race) - scfResource: removed waitForFunctionActive + local delay + SCF constants import; createResource now relies on createFunction's internal wait - tests: relocated 2 polling tests from scfResource.test to scfOperations.test (behavior moved); default GetFunction mock so waits complete; delete tests mock null for waitForFunctionDeleted Verified: 100 scf tests pass, tsc clean. Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…eserve i18n error messages)
Migrate the two bespoke polling loops to the shared pollUntil primitive. Error states (DELETED/FAILED) throw from the fetch wrapper; PollingTimeoutError is translated back to i18n messages (ES_APP_TIMEOUT_READY/DELETE) with { cause } so existing test assertions stay green and the original error is preserved.
Also bumps tsconfig target ES2020 -> ES2022: the eslint preserve-caught-error rule requires Error options ({ cause }), which needs lib ES2022. Node 24 fully supports it; 2581 unit tests pass.
Ultraworked with Sisyphus
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Migrate bespoke polling loops to the shared pollUntil primitive. Error states (DELETED/DELETE_FAILED) throw from the fetch wrapper; PollingTimeoutError translated to i18n RDS_INSTANCE_TIMEOUT_READY/DELETE with { cause }.
Ultraworked with Sisyphus
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…throwing timeout fallback) SLS waits never throw on timeout — they return a degraded object. PollingTimeoutError is caught and returns the fallback instead of rethrowing. Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…helper) Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
FC3 createFunction now polls GetFunction state==='Active' before returning (official readiness value, not lastUpdateStatus which is 'Successful' right after create). Add waitForFunctionActive before updates, waitForFunctionDeleted after delete. Terminates on state==='Failed'. Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…aits veFaaS GetFunction response carries Status (CLI already reads it); ready value is 'Active'. createFunction now polls until Active, update waits for Active first (update-before-active race), delete waits for the function to be gone (delete-then-recreate race). Mirrors SCF/FC3 pattern. Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
SCF state-persistence hardening: - create failures now propagate PartialResourceError with tainted state so a re-run reconciles instead of losing state (partial-failure loop fix) - tainted pre-flight: adopt our own half-created function, skip re-create - planner routes tainted state back to create - D1: already-exists on the NON-tainted path is deliberately not recoverable (removed ResourceInUse/已存在/already-exist from isRecoverableCreateError) — the cloud resource may not be owned by this stack, so it throws PartialResourceError for manual resolution. Only the tainted pre-flight path may adopt a pre-existing function. - trigger duplication guard on adoption + already-exists backstop
…(P1) - saveState: atomic write via tmp + fsync + rename; .backup of previous state before replace; failure leaves original intact (no corrupt file) - loadState: on JSON parse error try .backup, else throw StateCorruptError (no more silent empty-state -> full redeploy against existing cloud resources) - StateFile: serial (monotonic) + lineage (UUID) for drift detection - SaveStateFn -> Promise<void>; all 5 producers await backend.saveState - all 16 executor onStateChange call sites awaited — tainted state written on failure is now guaranteed persisted BEFORE the deployer throws, closing the fire-and-forget state-loss window for remote (COS/OSS/SaaS) backends
…Serverless (P2) - planners: tainted state -> create (cosPlanner, tdsqlcPlanner, esServerlessPlanner) - resources: create wrapped in try/catch, tainted state persisted before primary create, any post-create error throws PartialResourceError with the tainted state (no more silent blank-state on partial failure) - executors: PartialResourceError branch awaits onStateChange(updatedState) and returns partialFailure with updatedState - delete unify (Q3): cosResource.deleteBucketPolicy no longer swallows real errors — propagates, removeResource only on full success (NoSuchBucket/404 idempotent tolerance kept)
…se/tablestore/apigw (P2) - planners: tainted state -> create (oss/database/tablestore/apigw); apigw remote drift-import (findApiGroupByName) preserved for !currentState only - resources: tainted state persisted before primary create; post-create errors (transfer-acceleration, cdn, dns, domain bind, api create/deploy) throw PartialResourceError with tainted state; apigw domain-failure catch converted from return-state to throw - executors: PartialResourceError branch awaits onStateChange(updatedState), returns partialFailure with updatedState - delete unify (Q3): oss CDN/dns and apigw abolishApi/deleteApi/deleteApiGroup swallows removed — errors propagate, removeResource only on full success (existing NOT-FOUND tolerances kept)
- planners: tainted state -> create (tosPlanner, apigwPlanner); apigw drift-import (findGatewayByName) preserved for !currentState only - resources: tos create wrapped with tainted state before createBucket, post-create errors throw PartialResourceError; apigw partial state marked tainted, api/deploy/domain errors throw PartialResourceError, intermediate per-API state writes removed (final ready state carries all instances) - tosExecutor PartialResourceError branch (previously dead) now live - delete unify (Q3): vefaas deleteDependentResources, tos deleteBucketPolicy, apigw unbindDomain/deleteApi/deleteGateway swallows removed — errors propagate, removeResource only on full success (NOT-FOUND tolerances kept)
- acquireLockInternal: atomic lock creation via fs.openSync('wx') (O_EXCL)
eliminates the read->write->verify race where two processes could both
believe they held the lock; EEXIST falls into existing stale/dead-PID/
timeout handling unchanged; partial-file cleanup on write failure
- StateBackend.withLock: optional onLockAcquired(lockId) callback threaded
through local/remote/saas backends
- deploy/destroy: SIGINT/SIGTERM handlers release the active lock best-effort
(local is sync unlink; remote relies on stale-TTL recovery) then exit(130);
handlers removed in finally; idempotent against double-signal
- docs/state-locking.md: O_EXCL + crash-safety section
- StateVersionError: distinct error for state written by a newer CLI - registerStateMigration/clearStateMigrations: migration registry (empty today — CURRENT is 3.0); loadState applies registered migrations - migrateState: version-aware guard — only versions NEWER than CURRENT with no migration path are rejected (upgrade CLI); older/legacy versions (e.g. 1.0.0/0.0.1) load as-is; a migration chain that runs but fails to reach CURRENT throws rather than persisting partial state - loadState re-throws StateVersionError (not swallowed into StateCorruptError); migrations apply to backup-sourced states
…by jest testMatch)
- export migrateState from stateManager; remoteStateBackend.loadState and saasStateBackend.loadState now run migrateState before hydrating stages, so a newer-unknown state version in COS/OSS/SaaS throws StateVersionError instead of loading silently (was local-backend only) - legacy older versions (e.g. 1.0.0) still load as-is; tests added
…rror; add tainted pre-flight (reviewer N1) - oss/tos createBucketResource/createResource: upload failure no longer swallowed (was marked status:'ready' with code missing) — error propagates to the outer catch -> PartialResourceError with tainted state, so the next run retries the upload instead of silently reporting ready - tainted pre-flight (mirrors SCF): when state status is 'tainted' and the provider already has the bucket, skip createBucket and reuse it — closes the already-exists dead-loop on retry; D1-compliant (non-tainted path still throws on already-exists) - createBucket moved inside the try so create failures also persist tainted state; post-upload getBucket refresh retained
Aligns with AWS/gcloud credential file conventions — the API key in ~/.serverlessinsight/credentials.json is a long-lived secret and must not be world-readable. saveCredentials now chmods the file to 0600 after writing.
Root cause found via real deploy (deploy-fn-url.sh): HTTP trigger creation failed after the function was created, but trigger/domain code sat OUTSIDE the createFunction PartialResourceError try/catch — so the raw SDK error propagated to the executor's generic branch, which persisted the PRE-tainted state. On SaaS backend the tainted state never reached the console (deployment events showed start->fail with no state_sync), so a re-run could not reconcile. - scfResource: createTrigger non-already-exists errors and createCustomDomain errors now throw PartialResourceError(stateAfterDependents, error); the final provider refresh failure does too - fc3Resource: trigger + custom-domain creation wrapped in the same PartialResourceError protection; refresh failure aligned - tests: createTrigger failure -> PartialResourceError with tainted state (scf + fc3)
…refresh (reviewer B1) - HTTP_TRIGGER_AUTH_TYPE_REQUIRED validation moved to the top of createResource (before any cloud operation or tainted-state write) so an invalid trigger config can never strand a partially-created resource - final getFunction provider refresh wrapped: a rejection now throws PartialResourceError(stateAfterDependents, ...) instead of a raw error that lost the tainted marker (function already exists in cloud)
…unbind-drift + delete NOT-FOUND (reviewer) - fc3 createResource: final getFunction refresh wrapped in PartialResourceError (rejection no longer loses the tainted marker) - fc3 deleteDependentResources: removed the catch-all that swallowed sub-delete errors then removed state — SLS/RAM/SG/NAS orphans now propagate, removeResource only on full success - apigw updateApigwResource: unbindCustomDomain swallows removed (real errors propagate -> old state kept -> self-heal) instead of writing success-state with the cloud domain still bound (permanent DRIFT) - apigwOperations: isApigwNotFoundError helper; deleteApiGroup/deleteApi/ abolishApi/unbindCustomDomain tolerate NOT-FOUND (idempotent deletes) while real errors rethrow — delete no longer permanently stuck after mid-sequence failure; getApiGroup/getApi refactored to reuse it - apigw createApigwResource: tainted state written BEFORE group creation so a post-create failure persists the marker instead of orphaning the group - tests: rewrote 6 swallow-behavior tests, added create-taint/delete-tolerance/ mid-sequence tests
…oint (reviewer) - vefaas updateResource: tainted state written before createDependentResources so a partial TLS-creation failure persists the marker (PartialResourceError) instead of orphaning cloud resources with no retry path - tlsOperations createProject/createTopic/createIndex: tolerate *AlreadyExists/ResourceAlreadyExists/Conflict codes — log and adopt the existing resource, so a retry after partial failure no longer collides on the project name (previously permanently stuck) - service mockCloudClient: added missing sls deleteIndex/deleteLogstore/ deleteProject mocks (the fc3 delete-swallow fix exposed their absence)
…y SDK error messages Real deploy failure (deploy-fn-url.sh): a leftover HTTP trigger on the cloud function (from a previous partial run) made CreateTrigger fail with a BLANK error — the tainted state was persisted correctly, but the failure line showed nothing to diagnose, and retries kept hitting the same already-exists. - before createTrigger, always probe getFunction Triggers (not only on tainted adoption) — an already-attached trigger is now skipped instead of colliding - toErrorMessage helper: Tencent SDK errors carry code/message/requestId and may throw with an empty message — compose them so PartialResourceError always surfaces a diagnosable failure line - test: fresh create with trigger already attached in provider -> createTrigger skipped, no error
Root cause of the persistent deploy failure: the HTTP trigger's TriggerDesc
was built as {"authType":...} which is NOT the Tencent API Gateway trigger
format — CreateTrigger rejected it with InvalidParameterValue (previously
hidden by the blank-error bug). This affected both createResource and the
updateResource trigger-recreation path.
- buildTencentTriggerDesc(): produces the documented shape
{ api: { authRequired, requestConfig: { method }, isIntegratedResponse },
service: { serviceName: 'SCF_API_SERVICE' },
release: { environmentName: 'release' } }
authRequired: 'FALSE' when auth_type public (mapAuthType 'NONE'), else 'TRUE'
- create + update trigger paths both use it
- removed now-unused mapAccess import/access vars
- tests updated to assert the new format
Tencent shut down NEW API Gateway triggers on 2024-07-01 and the product
on 2025-06-30 (官方下线通知). The HTTP trigger previously used the API
Gateway TriggerDesc format { api/service/release }, which CreateTrigger now
rejects with InvalidParameterValue (the product no longer exists).
Per official docs (创建函数 URL), Type='http' with TriggerDesc:
{ AuthType, NetConfig: { EnableIntranet, EnableExtranet } } — AuthType is the
mapAuthType result ('NONE' public / 'CAM'), NetConfig maps from the trigger's
access (enableExtranet/enableIntranet).
- buildTencentTriggerDesc(authType, netConfig) now emits the Function URL shape
- create + update trigger paths pass access flags through
- tests updated to the new format
…tion Tencent's UpdateFunctionConfiguration rejects Handler and Runtime with InvalidParameterValue.Handler / .Runtime — they are set at creation time and cannot be changed. The update path was passing both, so a code-only change (update in-place) failed on every deploy once the function was ready. - updateFunctionConfiguration no longer sends Handler/Runtime - createFunction unchanged (still sends them — needed at creation) - test updated to assert the reduced param set
… for immutable Handler/Runtime The update path was ALWAYS calling UpdateFunctionConfiguration with the full config (including immutable Handler/Runtime), so every update-in-place — even a code-only change — was rejected by Tencent with InvalidParameterValue.Handler. - updateResource now diffs the mutable config fields (memorySize/timeout/ environment) between existing state and desired; UpdateFunctionConfiguration is only called when one actually changed (code-only updates skip it) - Handler/Runtime changes are a HARD error with a clear message (delete & recreate), instead of silently omitting them or hitting the opaque API error - tests: config-unchanged -> no re-push; mutable-field change -> re-push; Handler change -> clear immutable error
The browser flow opened `${consoleUrl.replace('api.', 'console.')}/cli/authorize`,
which only works for prod subdomains. With SI_CONSOLE_URL=http://localhost:3000
(dev API), the replace is a no-op, so si login opened the API server's
/cli/authorize → 404.
resolveConsoleUiUrl: SI_CONSOLE_UI_URL overrides; else prod (api.→console.);
else local (localhost:3000→localhost:5173); else pass through.
- tests: 4 new cases (env override / prod / local / passthrough)
server.close() only stops accepting new connections; the browser's keep-alive callback connection stayed open, keeping the event loop alive and hanging si login after 'Logged in' was printed. server.closeAllConnections() (added in this fix, Node 18+) forcibly closes it so the process exits.
The /callback success page was a bare text line. Now it returns an HTML
page that auto-redirects (3s meta refresh + manual link) to the Console
API-keys panel (${uiUrl}/${appId}/membership/api-keys) when the authorize
flow passes app_id, falling back to the Console root. Values are HTML-
escaped (not URL-encoded) so the redirect target stays intact.
- tests: callback carries app_id -> panel URL; no app_id -> root URL.
CLI: 2711 tests (1 new), tsc clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements SI CLI Console integration as designed in #210.
Summary
si login(browser + API key),si logout,si whoamiSaasStateBackendwith unifiedPOST /api/v1/deployments/endpoint, auto-provisioning, phase lifecycle~/.serverlessinsight/credentials.json,SI_API_KEYenv var,--si-api-keyflagkydependency with a thin nativefetchwrapper (zero deps, fixes jest ESM parsing ofkyin tests)Merged from master
feat: add triggers.http and domain support for Tencent SCF and Aliyun FC3 (#213)— merged in, no conflicts outstandingTest fixes
saasStateBackend.test.ts: rewritten to match current unified-endpoint API (credentials injected vialoadCredentials()mock,POST /api/v1/deployments/provisioning, phase lifecycle, forceUnlock/readLock)show.test.ts: fallback-to-local test now drives the realcreateStateBackendfailure pathCommits