mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
6952d3aa7f
* feat(shortcuts): import typed shortcut framework fromc07b64621* feat(extension): add public command contract * feat(extension): compile and register business commands * feat(cmd): assemble business command sets * fix(auth): derive login domains from shortcuts * test(extension): add business command test runtime * ci: verify generated Go sources * test(auth): match help and interactive domains * fix(extension): validate command path segments * fix(extension): satisfy generator and error guards * fix(extension): follow generated domain naming contract * fix(command): complete extension runtime contracts * test(command): cover public extension surface * fix(command): remove unused typed runtime APIs * fix(command): address follow-up review findings * fix(command): enforce extension v1 contracts * fix(command): remove unreachable compatibility wrappers * fix(auth): keep scope-less domains addressable via --domain, matching main Move the declared-scope filter from allKnownDomains to the interactive selector only. On main, a scope-less shortcut domain (event) passes --domain validation and fails later with "no matching scopes found"; the previous unified filter changed that to "unknown domain" and dropped it from the --help list. The interactive picker still hides scope-less domains — selecting one can only fail. * feat(command): add PathSegment for user-provided path values Business code concatenates IDs into request paths but had no public escape helper (internal/validate.EncodePathSegment is unreachable from extension/command). Mirror its url.PathEscape semantics, use it in the business command examples, and pin the traversal defense: the validator decodes percent-encoding before the canonical check, so both raw and escaped dot sequences fail same-origin validation. * docs(command): add runnable chat-brief distribution example Mirror the audit-observer precedent: a buildable wrapper main under examples/ showing WithCommandSets against the real distribution shape (plugins, strict mode, and service commands stay enabled). Covers the single-read command (Validate, shared DryRun request, CallJSON, PathSegment, Tips) and a Page[T] list command whose pagination flags come from the compiler. The testdata/wrapper fixture stays test-only. Verified offline: --help renders tag-driven parameters, +chat-brief-list exposes --page-all/--page-limit/--page-delay, and --dry-run previews the request with a fake env token and no network access. * fix(command): normalize page envelopes and bound CollectAllPages Two pagination contract fixes from the extension design (owner plan §8.3): Page decoding accepted only a literal "items" array, so endpoints that spell their list field differently (drive uses files, some responses use records) walked every page while decoding nothing — CollectAllPages then returned an empty set marked complete, and downstream writes ran against it. Each page now normalizes its single top-level array field into Page.Items; zero or multiple array fields fail closed with a typed invalid-response error. CollectAllPages previously reused the user-facing --page-limit maximum (1000) as its walk bound. A complete-set collection holds every page in memory before the workflow's writes run, so it now uses the design's dedicated workflow bound of 100 pages. * feat(commandhost): note bounded repetition in Page dry-run previews A Page[T] command's dry-run can only show the first request; fabricating response-dependent page tokens is forbidden. Append the bounded-repeat explanation to the previewed request (preserving any business description), matching the design's dry-run contract. Also give the example's list command a --page-token resume flag seeded into the request, documenting the resume convention: the framework owns --page-all/--page-limit/--page-delay while the starting cursor is a business-declared input, independent of --page-all. * docs(command): generate domain constants with English comments The generator emitted Chinese titles while the rest of the public extension packages document in English. Switch the generator to the "en" service title and regenerate. The generator already rejects a domain missing either locale, so the switch keeps its own guard. * docs(command): mark the host adapter read surface The Host* types, InspectCommand, InspectDomain and CloneSets exist for lark-cli's host adapter, not for business commands, but nothing said so at the symbols themselves. They cannot move to a subpackage: a Command holds its declaration unexported, so a sibling package has no way to reach it, and moving the wire types to internal/ would cycle back through CommandMetadata and CommandContext. Also correct HostPagination, which is not adapter-only -- ContextOptions and commandtest both carry it. * refactor(command): type CommandMetadata.Service as DomainName A set already declares its domain through ExtendDomain(DomainIm), yet every command repeated the same domain as a bare string that only the host compiler checked. Typing the field points authors at the generated enum and forces an explicit conversion when the value comes from a string variable. This does not make a mistyped literal a compile error -- an untyped constant still converts to DomainName -- so the mismatch check in CompileSets stays the actual net. The example and the wrapper fixture now declare command.DomainIm. The chat-brief example was also not gofmt-clean, which the CI format gate would have caught. * refactor(command): extract the command-set assembly steps buildInternalWithConfig is an orchestrator, and the business command sets were compiled inline inside it. Move that step to resolveShortcutSnapshot so the entry point reads as one call and the built-in/external merge has a name. newCommand carried six near-identical blocks that each nil-checked a hook and wrapped it in the same type assertion. Split them into one binder per hook shape; Normalize and Validate now share bindArgsHook since their signatures match. The behaviour is unchanged: an undeclared hook still erases to nil, and an empty renderer map still yields nil. * perf(shortcuts): stop re-cloning an already-isolated snapshot AllShortcuts deep-copies because a Shortcut carries slice fields whose backing arrays a shallow copy would share: an external distribution mutating registered[0].Flags[0] would corrupt the process-global list. That copy is worth its ~165us over 500+ shortcuts. Paying it four times per startup is not. auth, schema and the mount path each cloned the snapshot again, but they receive it from AllShortcutsWithExternal with no third-party code in between, and nothing in this repository mutates a shortcut element -- mountDeclarative takes a value receiver and only replaces slice headers. Drop those three copies and document the boundary on AllShortcuts so the next reader does not reintroduce them. Startup drops from four full clones to one. Benchmarks pin the remaining cost so a regression points at a new clone rather than at growth in the shortcut set. * fix(command): align the commandtest page bound and escape wrapper paths Two review findings, both of which let a business command pass its tests and then misbehave in production. The commandtest recorder walked 1000 pages for a complete-set collection while the host adapter stops at 100, so a command tested against 300 pages of fixtures would fail its first real --page-all run with PaginationLimitError. The bound now lives in internal/pagination, which both sides already import, and the hard-limit test scripts itself from that constant instead of restating 1000 -- the literal was what let the two drift apart. The testdata wrapper concatenated args.ID straight into the request path, contradicting PathSegment's own documented rule and the chat-brief example. ValidateRequestView does not cover this: "abc/other-users-file" cleans to itself, so an unescaped separator silently retargets the request. Since testdata is what an integrator copies first, route both call sites through one readRequest helper, mirroring chat-brief. The e2e assertion could not have caught it either -- PathSegment("chat_1") is "chat_1", so the check passed with or without the call. It now sends "chat/1" and asserts %2F reaches the wire; removing PathSegment fails it. * fix(command): deny network to the pre-confirmation hooks and four review findings Normalize and Validate run before the high-risk confirmation gate, and both received the full CommandContext, so a high-risk business command could POST or DELETE from Validate and leave remote side effects behind before the user was ever asked to confirm. Moving the gate earlier would contradict the documented hook order and would also make --dry-run require --yes. The design already forbids this from the other side -- Validate is specified as parameter checking that issues no request -- so enforce that instead: Normalize and Validate get a context whose CallJSON and CollectPages refuse, while PreflightScopes stays available. The guard sits in CommandContext rather than in the wiring, so a future adapter that wires the callbacks anyway still cannot reach the API. commandtest mirrors it, otherwise a command would pass its tests and fail only in production. Page.Items now starts non-nil. It is declared required;nonnullable, but a zero-item collection encoded as {"items":null}, which a caller generating types from the published schema would reject. NewCmdAuthWithRecovery and NewCmdSchemaWithVisibility are restored as wrappers. Both were dropped for shortcut-aware variants, and both are reachable from outside this module: CommandVisibility is an ordinary exported func type, and *recovery.Projector cannot be named by an outside caller but can be passed as nil. A signature test now pins them. The path-traversal fixture said "../../secret", which the deterministic gate rejects as a generic credential assignment -- the reason CI is currently red. The filename carries no meaning; it is now "../../outside". * test(cmd): exercise the retained constructors instead of naming them The compatibility wrappers restored for outside callers are unreachable from inside this repository by construction, so the incremental dead-code gate rejected them. A signature-only assertion did not help: taking a function value and discarding it leaves the body unreachable, and it proved nothing about whether the wrapper still builds a working command. Call each one and assert the command it returns. NewCmdAuthWithRecovery is called with a nil projector, which is the exact call an outside module can make and the reason the wrapper has to keep compiling. Verified with the same deadcode version CI runs: neither function is reported, and no other function in this branch's files is either. * docs(command): document the hook contract and pin dry-run note idempotency Hooks is the first type a business author reads and carried no field documentation, so the rules lived only in the design doc: which of DryRun and DryRunE to set, that setting both fails to compile, that Execute owns the API call and must not write stdout, and that Normalize and Validate run before the confirmation gate and therefore get no network. The choice between DryRun and DryRunE is not old-versus-new -- neither is legacy. It follows from whether building the preview can fail, which is now what the field docs say. Also pin the dry-run note as idempotent. convertDryRun writes the bounded-repeat note into the projection it builds, never back into the hook's *DryRun, and DryRunAPI.Desc assigns rather than appends, so a hook that caches and returns the same preview cannot accumulate the note. Both properties were true and neither was tested. * refactor(command): settle the dry-run constructor, tips, and domain enum Three narrowings of the V1 business-command contract, none of which has a published compatibility surface: extension/command does not exist on main. Preview and NewDryRun were the same constructor twice -- one empty, one seeded with requests. Fold them into a variadic NewDryRun. Every existing NewDryRun() call keeps compiling, and the domain word in the contract is now spelled one way. The type DryRun already owns that identifier in this package, so naming the constructor DryRun outright cannot compile. Drop Metadata.Tips. It was pure passthrough into common.Shortcut.Tips and nothing in the execution path read it, so business commands lose only the ability to declare help tips; the repository's own typed shortcuts keep theirs. The mount test asserted a tip reached the rendered help as proof that metadata survives the extension -> commandhost -> common.Shortcut -> help conversion; it now asserts the risk line, which travels the same path. Hand-write the domain enumeration and delete the generator. Generating from shortcuts.AllShortcuts silently omitted approval, attendance and mindnotes: all three are published under `lark-cli --help` and served by typed and raw API commands, they just own no shortcut. The enum is now the 23 domains the CLI actually exposes. Those three would otherwise have been constants that compile and always fail, because CompileSets derived its mountable domains from the same shortcut list. It now reads the service registry, and shortcuts/register.go already creates a domain command group on demand when no built-in occupies it, so a business command can mount under a shortcut-less domain. * ci: stop generating extension/command The domain enumeration is hand-written now and extension/command holds no go:generate directive, so the path was a no-op that still read as if the package carried generated files. * refactor(command): drop DryRunE and let the preview render like a built-in DryRunE has no counterpart in the shipped CLI: `git show main:shortcuts/common/types.go` has no such field, it arrived with the typed-shortcut framework this branch imported, and no shortcut in the repository sets one. Business commands get the single DryRun hook that built-in shortcuts have. Validate already runs before it and owns the error channel, so a preview that cannot be built still fails there with a typed error -- which is what the repointed tests now assert, end to end through --dry-run and through commandtest.Preview. Also stop appending the bounded-repeat note. convertDryRun added "with --page-all, repeats with the returned page_token until exhaustion or --page-limit" to every Page[T] preview, so an external command's dry-run carried a sentence its author never wrote. Built-in paginated shortcuts say this themselves when they want it (im_chat_members_list.go calls dry.Desc), and external commands now do the same: the framework renders the description it was given and nothing else. The dry-run context keeps refusing requests -- runner.go does the same for built-ins, so removing that would be the divergence, not the alignment. Only the word changes: "offline" was our own vocabulary for what the rest of the CLI calls dry-run. * refactor(command): drop the partial-failure outcome from the contract Business commands now return Success only. Partial, OutcomeDefinition, PartialFailureDefinition and FailedItemDefinition leave the public surface along with Execution.Partial and the host adapter's receipt conversion. Result keeps its outcome field. It is no longer a choice -- Success is the only value -- but it is also how the host tells a returned Result apart from the zero value that accompanies an error, which is the check commandtest.Execute makes before reporting "returned both Result and error". Collapsing it to nothing would delete that signal. The exemplar commands that returned Partial keep their scenarios: a best-effort scope failure still marks every item failed and appends the snapshot, and the multi-call audit still records the owner it could not resolve. That information lives in the command's own Data (Items[].State plus Failures), not in the outcome, so the tests assert the same facts and only the outcome assertion is gone. The deep-copy test moved its nested JSON exemplar from FailedValues to InputDefault.Value, keeping cloneJSONValue covered. shortcuts/common still defines PartialFailure for built-in typed shortcuts. That is the imported framework, untouched here. * refactor(shortcuts): walk pages once for built-in and external commands Commit4d0c6ea61added internal/pagination, moved PaginateInto onto it, and then wrote a second caller for externally declared commands. Both assembled the same Walk options, cloned the same params, read the same cursor and mapped the same walk error; only the policy source, the call path and the accumulator ever differed. Those three now parameterize one pageWalk. PaginateInto keeps calling through the RuntimeContext and keeps its per-page progress line; external commands keep CallTypedAPI, the walker's context and their undecoded pages, which the public contract needs because it decodes them into its own Page[T]. Behavior is unchanged on both sides -- the external walk still leaves Wait nil, which internal/pagination fills with WaitContext, so --page-delay works exactly as before. pageWalk is deliberately generic-free so one struct serves both callers; the typed half of the built-in path moved to addDecodedPage. * refactor(shortcuts): make the external page walk PaginateInto's twin CollectCommandPages now differs from PaginateInto only where the context type forces it. It takes the same PageAccumulator, decodes each page into T through the same addDecodedPage, returns the same *output.PaginationMeta and reads the same state out of the same walk, in the same order. What is left is what the interface cannot supply. An externally declared command compiles in the business module, so it holds a CommandContext rather than a *RuntimeContext: the context arrives as a parameter because the interface carries none, the call goes through CallTypedAPI, and there is no progress line because deciding to print one needs StderrIsTerminal, JqExpr and Format, none of which the interface exposes. The all parameter stays. It is the complete-set policy CollectAllPages depends on -- collect to exhaustion under the hard page bound instead of obeying --page-all and --page-limit -- and PaginateInto has no way to express it, since resolvePaginationPolicy only ever reads flags. Dropping it would quietly turn a command that must see the whole set into one a user can truncate with --page-limit 1. CommandPageCollection is gone with it: pages accumulate in commandhost's own accumulator, the way every built-in shortcut already accumulates its own. One consequence of sharing the decode: a page whose response carries no data object is now an error on this path too, as it always was for built-ins. * fix(shortcuts): reject recursive Data and Args types during compilation shapeForType and compileStructShape called each other without recording the Go types already being walked, so a self-referential type recursed forever. The walk ran during command registration and ended in a stack overflow -- a fatal runtime error rather than a panic, so no recover boundary could contain it and one extension command took the whole CLI down before --help, schema, or any unrelated command could run. That also broke CompileErasedDefinition's documented promise to compile without panic. Thread the struct types open on the current recursion path through both functions and return a compile error on a repeat visit, pointing at the explicit Shape escape hatch. Membership is scoped to the path, not the whole walk, so a type reused as a sibling or at another depth stays legal. Covers self-reference through a slice and through a pointer, mutual recursion through two types, the JSON-encoded Args path, and the public CompileErasedDefinition contract. * fix(command): share one result protocol between the host and commandtest commandtest.Execute only checked that Data carried the expected type. Generic erasure leaves a correctly typed zero Data behind, so a business command that returned Result{} instead of Success(data) passed the type assertion and the test reported success. Production rejects the same result, which left extension authors with green tests and a command that failed on every real invocation -- exactly the guarantee commandtest exists to provide. Add ValidateHostResult in extension/command and call it from both commandtest.Execute and the host adapter's execute hook, so the two surfaces cannot drift. It rejects an empty or unsupported outcome and mirrors the pagination receipt checks the host already applies: declared Page output, pages of at least one, non-negative items, and next-token state consistent with completeness. RunWithFlags is covered because it delegates to Execute. * feat(command): expose reusable download capabilities * test(command): make the chat-brief example testable and cover its hooks The example declared both commands as inline Definition literals handed straight to Define. Define erases the type parameters and returns an opaque Command that cannot hand its Definition back, while commandtest.Execute takes the Definition -- so the shape the example demonstrated could not be unit tested at all. Neither shipped example had a test file, so nobody had walked the copy-the-example-then-add-a-test path. Lift both declarations into Definition-returning functions, the shape the repository's own commandtest suites already use, and keep the compiled Commands as package vars so main is unchanged. The configuration bodies are untouched. Add the tests that shape exists for: the single-read projection and its Validate rejection, plus the Page[T] contract for default single-page reads and a --page-all walk through RunWithFlags. All four run offline through the commandtest recorder. * test(commandtest): cover the ordered URL download script Recorder.ReplyURL shipped without a caller, so the incremental dead code gate flagged it as new unreachable code and blocked the branch. The existing URL download test uses the unordered RespondFile constructor, which never exercises the URL assertion ReplyURL exists for. Mirror the ReplyJSON pair: one test walks two scripted URLs in order and checks the recorded source URLs, content types, and artifacts; the other points DownloadURL at an unscripted URL and expects the mismatch error. * feat(command): accept @file input for external commands V1 rejected the file value source, leaving external commands with inline flags and stdin only. A process has one stdin, so a command whose body is too large or too quoted for the shell -- an XML document update is the case that surfaced this -- had no second way to receive it, and the caller had to fall back to shell escaping. Nothing downstream was missing: resolveInputFlags already resolves @path and the @@ escape through the invocation's FileIO, help renders the "@file" affordance, and legacyInputSources maps the source onto the compiled flag. The gap was the public constant and the host allow-list. Export SourceFile and let it through compilation. Unknown sources still fail the same way, which the rewritten host test now pins alongside the compiled flag actually carrying both extra sources -- silently dropping a declared source is the regression worth catching. Give the wrapper fixture a command whose content flag declares all three sources, and drive it end to end: @file and stdin both reach the request body, and the help text advertises them. * revert(content): drop the default content exports from the extension surface Exporting the repository's embedded skills and affordance trees turned two content directories into Go packages, because go:embed cannot reach up out of a package directory and the repository root is package main. That cost two things: a .go file living inside an authored-content tree, where a future content type is silently omitted until someone edits the glob, and an embed directive per tree where the root previously covered both in one. The need it served does not exist. The distribution driving this work ships its own skills and does not consume the official set, and a wrapper that does want them can supply a tree through cmd.SetEmbeddedSkillContent, which is what extension/platform already documents. Restore content_embed.go, the SkillsOverlay comments, and the platform README to their main state, and delete the two exporting packages. The example and fixture wrappers now ship no embedded content, which is what a wrapper that does not compile the repository root actually gets; the e2e assertion that depended on inheriting lark-doc goes with it. * fix(command): close the review findings on API surface and storage commit Five findings from the extension-v1 review, each verified by a test that fails against the previous implementation. Source compatibility of auth.LoginOptions. The shortcut snapshot was an unexported field on a struct that appears in the exported runF signature, which ends positional literals for every caller outside this module. It becomes a closure capture plus an explicit authLoginRun parameter, and the six domain helpers collapse into domainResolver methods, removing five xxxWithShortcuts twins that only tests reached. common.Shortcut.DryRunE goes too: it was a new exported field with no production caller. The unexported typed field stays, so Shortcut itself is still not positional-literal compatible -- that is a deliberate remaining gap, since relocating it would need a global mutable map or a wider signature change. One canonical wire projection. queryValues stringified and dropped nils for the live call while the dry-run preview and the pagination walk forwarded raw values, so a preview could describe a request the runtime would never send. canonicalQuery is now the only projection and all three consumers derive from it. Dry-run output for numeric parameters therefore reads "20" instead of 20, matching what the query string actually carries. No-clobber as a storage guarantee. IfExistsFail checked existence, downloaded, then committed with a rename that replaces unconditionally, so a target created during the transfer was silently overwritten. The commit step is now an optional ExclusiveFileIO capability: content lands in a temp file and is published with Link, which refuses an existing target and never exposes a partial file. A provider without the capability is refused rather than served a guarantee it cannot keep. V1 public surface. Removes NewDomain and its options (host compilation rejected them), HostDomain.IsNew, reservedRootNames, and the unproducible ResultMetaDefinition.Count; narrows Hooks.Renderers to a single PrettyRenderer, since pretty was the only key the compiler accepted; and demotes the generic authoring layer in shortcuts/common to unexported, as no production code outside that package used it. commandtest runs the production compiler. Execute, RunWithFlags and Preview now share compileForTest, so a wrong tag, Shape or relation fails in the unit test instead of at CLI startup. Applying this surfaced two long-standing contract violations in the package's own fixture. * refactor(command): keep a single authoring contract * revert(schema): keep the schema command blind to shortcuts The schema command serves the generated API catalog only, matching main. Drop the shortcut contract lookup and completion from cmd/schema and restore the constructor surface. ShortcutSchema stays on the sealed commandbridge surface, where the host compiler tests assert the contract; the CLI itself no longer consumes it. The surface scenario and wrapper e2e pin the boundary from the other side: schema resolution and completion must not see mounted shortcuts. --------- Co-authored-by: sang-neo03 <266690410+sang-neo03@users.noreply.github.com> Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>