@teemops/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +113 -0
  2. package/dist/teemops.js +221 -46
  3. package/package.json +1 -1
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # teemops CLI
2
+
3
+ `@teemops/cli` is the command line for [teemops](https://teemops.com), Teem's cloud security
4
+ scanner. People use it from a terminal, and agents and CI jobs use it to start scans, read
5
+ findings and fail a build on a new critical. Every command prints JSON when its output is not a
6
+ terminal, so you don't need to scrape anything.
7
+
8
+ Requires Node.js 22 or later.
9
+
10
+ ```bash
11
+ npm install -g @teemops/cli
12
+ ```
13
+
14
+ Or run it without installing:
15
+
16
+ ```bash
17
+ npx @teemops/cli help
18
+ ```
19
+
20
+ ## Sign in
21
+
22
+ ```bash
23
+ teemops login
24
+ ```
25
+
26
+ This signs you in with your Teem account. It prints a link and a code; open the link in any
27
+ browser and enter the code. `teemops whoami` shows who you are signed in as and which
28
+ organization you are acting in. `teemops logout` signs out.
29
+
30
+ ## Agents and CI
31
+
32
+ An agent is an identity in your organization with its own role and credential, so it doesn't
33
+ use a person's sign-in. An owner or administrator creates one:
34
+
35
+ ```bash
36
+ teemops agents create --name ci --role auditor
37
+ ```
38
+
39
+ The credential is shown once. Store it in your CI's secret store or your agent's environment
40
+ as `TEEMOPS_TOKEN`, and every command uses it:
41
+
42
+ ```bash
43
+ teemops findings list --fail-on high
44
+ ```
45
+
46
+ Roles are `viewer` (read only), `auditor` (also runs scans and resolves or ignores findings) and
47
+ `administrator` (also connects accounts and manages agents). Give an agent the smallest role that
48
+ does its job. Billing and other owner-only actions need a person signed in as the owner. Credentials expire after 30, 90 or 365 days
49
+ (`--expires-days`, default 90). To rotate one without downtime, add a second secret, switch
50
+ over, then revoke the old one:
51
+
52
+ ```bash
53
+ teemops agents add-secret --id <agent-id>
54
+ ```
55
+
56
+ ```bash
57
+ teemops agents revoke-secret --id <agent-id> --secret-id <old-secret-id>
58
+ ```
59
+
60
+ `teemops agents list` shows each agent and its secrets, and `teemops agents revoke --id <agent-id>`
61
+ deletes an agent. Personal access tokens (`teemops tokens …`) work the same way for a person.
62
+
63
+ ## Common commands
64
+
65
+ | Command | What it does |
66
+ | --- | --- |
67
+ | `teemops accounts list` | Connected cloud accounts |
68
+ | `teemops accounts connect` | Start connecting an AWS account; returns a link for a person to open |
69
+ | `teemops scan start --account <id> --wait` | Scan an account and wait for the result |
70
+ | `teemops findings list` | Open findings, most severe first. Filter with `--severity`, `--account`, `--service`, `--region` |
71
+ | `teemops findings get --id <id>` | One finding and how to fix it |
72
+ | `teemops insights` | Health score, the factors behind it, and KPIs |
73
+ | `teemops members list` | Your organization's members |
74
+ | `teemops billing …` | Your plan |
75
+
76
+ `teemops help` lists every command. `teemops help --json` returns the full command tree, with
77
+ flags, environment variables and exit codes, for an agent to read.
78
+
79
+ ## Gating a build
80
+
81
+ `--fail-on <severity>` exits with code 5 when any open finding is at or above that severity:
82
+
83
+ ```bash
84
+ teemops scan start --account 123456789012 --wait && teemops findings list --fail-on critical
85
+ ```
86
+
87
+ ## Exit codes
88
+
89
+ | Code | Meaning |
90
+ | --- | --- |
91
+ | 0 | Success |
92
+ | 1 | Error, or a scan that did not succeed |
93
+ | 2 | Usage error |
94
+ | 3 | Not signed in, or the token was refused |
95
+ | 4 | Not found |
96
+ | 5 | `--fail-on` matched findings |
97
+ | 6 | Conflict: no plan, over a plan limit, or a billing conflict |
98
+
99
+ ## Environment
100
+
101
+ | Variable | Purpose |
102
+ | --- | --- |
103
+ | `TEEMOPS_TOKEN` | An agent credential or personal access token. Takes precedence over `teemops login` |
104
+ | `TEEMOPS_API_URL` | API base URL. Default `https://api.teemops.com` |
105
+
106
+ ## MCP
107
+
108
+ The same API is available to MCP clients at `https://api.teemops.com/mcp`, with
109
+ `TEEMOPS_TOKEN` as the bearer token.
110
+
111
+ ## License
112
+
113
+ GPL-3.0-only
package/dist/teemops.js CHANGED
@@ -19739,6 +19739,8 @@ var MeResponse = external_exports.object({
19739
19739
  /** auth.teem.cloud subject. Null for an agent, which acts for the org, not a person. */
19740
19740
  userId: external_exports.string().nullable(),
19741
19741
  email: external_exports.email().nullable(),
19742
+ /** The person's name, for "[First name]'s organization". Filled for a person with no active org; null when not known. Optional so a newer client still parses an older API. */
19743
+ name: external_exports.string().nullable().optional(),
19742
19744
  /** Null when signed in but no active org yet (auth.teem.cloud sends no org claim). */
19743
19745
  org: external_exports.object({
19744
19746
  id: external_exports.string(),
@@ -19817,7 +19819,10 @@ var Account = external_exports.object({
19817
19819
  connectedAt: external_exports.iso.datetime(),
19818
19820
  /** When the next daily scan starts (UTC); null while paused or when the account has no slot yet. Defaults so a newer CLI parses an older API. */
19819
19821
  nextScanAt: external_exports.iso.datetime().nullable().default(null),
19820
- /** Why scheduled scans are off for this account: the org has no plan, or the plan covers only older accounts. */
19822
+ /**
19823
+ * Why scans are off for this account: the org has no plan (and the account's free scan is used or unavailable),
19824
+ * or the plan covers only older accounts.
19825
+ */
19821
19826
  scanPaused: external_exports.enum(["no_plan", "over_limit"]).nullable().default(null)
19822
19827
  });
19823
19828
  var AccountListResponse = external_exports.object({ data: external_exports.array(Account) });
@@ -19852,16 +19857,18 @@ var Finding = external_exports.object({
19852
19857
  provider: external_exports.enum(["aws"]),
19853
19858
  /** The cloud account's own id (12 digits for AWS). */
19854
19859
  accountId: external_exports.string(),
19855
- /** `global` for global services. */
19856
- region: external_exports.string(),
19860
+ /** `global` for global services. `null` while the response is locked (X-11). */
19861
+ region: external_exports.string().nullable(),
19857
19862
  service: external_exports.string(),
19858
- resourceType: external_exports.string(),
19859
- resourceId: external_exports.string(),
19863
+ /** `null` while the response is locked (X-11). */
19864
+ resourceType: external_exports.string().nullable(),
19865
+ /** `null` while the response is locked (X-11). */
19866
+ resourceId: external_exports.string().nullable(),
19860
19867
  firstSeenAt: external_exports.iso.datetime(),
19861
19868
  lastSeenAt: external_exports.iso.datetime(),
19862
19869
  resolvedAt: external_exports.iso.datetime().nullable(),
19863
- /** `skill` names the AR-1 remediation skill, once those exist. */
19864
- remediation: external_exports.object({ text: external_exports.string(), skill: external_exports.string().optional() }),
19870
+ /** `skill` names the AR-1 remediation skill, once those exist. `null` while the response is locked (X-11). */
19871
+ remediation: external_exports.object({ text: external_exports.string(), skill: external_exports.string().optional() }).nullable(),
19865
19872
  frameworks: external_exports.array(external_exports.string())
19866
19873
  });
19867
19874
  var FindingsQuery = external_exports.object({
@@ -19875,8 +19882,8 @@ var FindingsQuery = external_exports.object({
19875
19882
  /** Opaque, from the previous page's `nextCursor`; only valid with the same filters. */
19876
19883
  cursor: external_exports.string().min(1).optional()
19877
19884
  });
19878
- var FindingListResponse = external_exports.object({ data: external_exports.array(Finding), nextCursor: external_exports.string().nullable() });
19879
- var FindingResponse = external_exports.object({ data: Finding });
19885
+ var FindingListResponse = external_exports.object({ data: external_exports.array(Finding), nextCursor: external_exports.string().nullable(), locked: external_exports.boolean().default(false) });
19886
+ var FindingResponse = external_exports.object({ data: Finding, locked: external_exports.boolean().default(false) });
19880
19887
 
19881
19888
  // ../../packages/contracts/src/scans.ts
19882
19889
  var ScanStatus = external_exports.enum(["queued", "running", "succeeded", "partial", "failed"]);
@@ -19938,7 +19945,7 @@ var InsightsResponse = external_exports.object({
19938
19945
  score: external_exports.number().int().min(0).max(100).nullable(),
19939
19946
  band: HealthBand.nullable(),
19940
19947
  weights: HealthWeights,
19941
- factors: external_exports.array(Factor),
19948
+ factors: external_exports.array(Factor).nullable(),
19942
19949
  kpis: external_exports.object({
19943
19950
  openNow: external_exports.number().int(),
19944
19951
  /** Open at the period start, reconstructed from firstSeenAt and resolvedAt. */
@@ -19951,22 +19958,21 @@ var InsightsResponse = external_exports.object({
19951
19958
  coverage: external_exports.number().int().min(0).max(100).nullable(),
19952
19959
  /** Days, to one decimal, for criticals resolved in the period; null when none were. */
19953
19960
  medianDaysToFixCritical: external_exports.number().nullable()
19954
- }),
19955
- frameworks: external_exports.array(FrameworkScore),
19961
+ }).nullable(),
19962
+ frameworks: external_exports.array(FrameworkScore).nullable(),
19956
19963
  /** 12 equal buckets across the period, oldest first. `end` is exclusive except for the last bucket. */
19957
- trend: external_exports.array(
19958
- external_exports.object({ start: external_exports.iso.datetime(), end: external_exports.iso.datetime(), raised: external_exports.number().int(), resolved: external_exports.number().int() })
19959
- ),
19964
+ trend: external_exports.array(external_exports.object({ start: external_exports.iso.datetime(), end: external_exports.iso.datetime(), raised: external_exports.number().int(), resolved: external_exports.number().int() })).nullable(),
19960
19965
  /** Open now, ignoring the period, so it matches Findings. */
19961
19966
  openBy: external_exports.object({
19962
19967
  severity: external_exports.record(Severity, external_exports.number().int()),
19963
- service: external_exports.array(external_exports.object({ service: external_exports.string(), ...bySeverity })),
19964
- framework: external_exports.array(external_exports.object({ id: external_exports.string(), ...bySeverity }))
19968
+ service: external_exports.array(external_exports.object({ service: external_exports.string(), ...bySeverity })).nullable(),
19969
+ framework: external_exports.array(external_exports.object({ id: external_exports.string(), ...bySeverity })).nullable()
19965
19970
  }),
19966
19971
  period: InsightsPeriod,
19967
19972
  account: external_exports.string().nullable(),
19968
19973
  generatedAt: external_exports.iso.datetime()
19969
- })
19974
+ }),
19975
+ locked: external_exports.boolean().default(false)
19970
19976
  });
19971
19977
  var WeightsResponse = external_exports.object({ data: HealthWeights });
19972
19978
 
@@ -19994,6 +20000,12 @@ var Plan = external_exports.enum(["solo", "team", "business"]);
19994
20000
  var PlanOrNone = external_exports.enum(["none", ...Plan.options]);
19995
20001
  var BillingStatus = external_exports.enum(["none", "trialing", "active", "past_due", "ended"]);
19996
20002
  var Currency = external_exports.enum(["usd", "nzd", "aud"]);
20003
+ var BillingCountry = external_exports.enum(["nz", "au", "other"]);
20004
+ var COUNTRY_CURRENCIES = {
20005
+ nz: ["nzd"],
20006
+ au: ["aud", "usd"],
20007
+ other: ["usd"]
20008
+ };
19997
20009
  var PlanLimits = external_exports.object({ users: external_exports.number().int(), cloudAccounts: external_exports.number().int() });
19998
20010
  var Entitlements = external_exports.object({
19999
20011
  plan: PlanOrNone,
@@ -20017,6 +20029,13 @@ var PlanOffer = external_exports.object({
20017
20029
  })
20018
20030
  )
20019
20031
  });
20032
+ var FreeScanStatus = external_exports.enum(["running", "succeeded", "partial", "expired"]);
20033
+ var FreeScan = external_exports.object({
20034
+ status: FreeScanStatus,
20035
+ finishedAt: external_exports.iso.datetime().nullable(),
20036
+ /** `finishedAt` plus 14 days: when the free results are (or were) deleted unless a plan is chosen. Null while running. */
20037
+ deletesAt: external_exports.iso.datetime().nullable()
20038
+ });
20020
20039
  var BillingResponse = external_exports.object({
20021
20040
  data: external_exports.object({
20022
20041
  entitlements: Entitlements,
@@ -20025,12 +20044,109 @@ var BillingResponse = external_exports.object({
20025
20044
  canManage: external_exports.boolean(),
20026
20045
  /** Whether Checkout would offer the $1, 30-day start. */
20027
20046
  introOffer: external_exports.boolean(),
20028
- plans: external_exports.array(PlanOffer)
20047
+ plans: external_exports.array(PlanOffer),
20048
+ /** Null until the org's free scan starts, and again if it failed (it can be retried). */
20049
+ freeScan: FreeScan.nullable().default(null),
20050
+ /** An owner of the org, for "Only [owner] can choose a plan". Null when none has a known email. Memberships store no name. */
20051
+ owner: external_exports.object({ email: external_exports.email() }).nullable().default(null)
20029
20052
  })
20030
20053
  });
20031
- var CheckoutRequest = external_exports.strictObject({ plan: Plan, currency: Currency });
20054
+ var CheckoutRequest = external_exports.strictObject({ plan: Plan, country: BillingCountry, currency: Currency }).refine((r) => COUNTRY_CURRENCIES[r.country].includes(r.currency), { message: "That currency is not available in that country", path: ["currency"] });
20032
20055
  var SessionUrlResponse = external_exports.object({ data: external_exports.object({ url: external_exports.url({ protocol: /^https$/ }) }) });
20033
20056
 
20057
+ // ../../packages/contracts/src/notifications.ts
20058
+ var SLACK_WEBHOOK_PATTERN = /^https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/_-]+$/;
20059
+ var NotificationSettings = external_exports.object({
20060
+ email: external_exports.object({ enabled: external_exports.boolean() }),
20061
+ slack: external_exports.object({
20062
+ configured: external_exports.boolean(),
20063
+ last4: external_exports.string().nullable(),
20064
+ /** Slack answered 404 or 410: the webhook is gone, and teemops has stopped posting until it is replaced. */
20065
+ broken: external_exports.boolean()
20066
+ })
20067
+ });
20068
+ var NotificationSettingsResponse = external_exports.object({ data: NotificationSettings });
20069
+ var UpdateNotificationsRequest = external_exports.object({
20070
+ emailEnabled: external_exports.boolean().optional(),
20071
+ slackWebhookUrl: external_exports.string().max(300).regex(SLACK_WEBHOOK_PATTERN).nullable().optional()
20072
+ });
20073
+ var TestNotificationRequest = external_exports.object({ channel: external_exports.enum(["email", "slack"]) });
20074
+ var TestNotificationResponse = external_exports.object({ data: external_exports.object({ sent: external_exports.literal(true) }) });
20075
+
20076
+ // ../../packages/contracts/src/events.ts
20077
+ var MemberJoinedRole = external_exports.enum(["owner", "admin", "member"]);
20078
+ var OrgMemberJoinedV1 = external_exports.object({
20079
+ orgId: external_exports.string().min(1),
20080
+ orgName: external_exports.string(),
20081
+ userId: external_exports.string().min(1),
20082
+ email: external_exports.email(),
20083
+ role: MemberJoinedRole,
20084
+ /** Epoch milliseconds. */
20085
+ at: external_exports.number().int().nonnegative()
20086
+ });
20087
+ var BillingSubscriptionChangedV1 = external_exports.object({
20088
+ orgId: external_exports.string().min(1),
20089
+ orgName: external_exports.string().nullable(),
20090
+ /**
20091
+ * IdP org owners as last seen by teemops. May omit owners who never signed in to teemops or whose email
20092
+ * isn't filled yet, and may include recently demoted or removed owners. The paying owner is always
20093
+ * covered by `stripeCustomerEmail`.
20094
+ */
20095
+ ownerEmails: external_exports.array(external_exports.email()),
20096
+ status: BillingStatus,
20097
+ plan: PlanOrNone,
20098
+ trialEndsAt: external_exports.iso.datetime().nullable(),
20099
+ currentPeriodEnd: external_exports.iso.datetime().nullable(),
20100
+ stripeCustomerEmail: external_exports.email().nullable(),
20101
+ /** The most recently opened Checkout session, not necessarily the one that was paid. */
20102
+ checkoutSessionId: external_exports.string().nullable(),
20103
+ /** Epoch milliseconds of the Stripe read this snapshot reflects; a consumer keeps the latest. */
20104
+ asOf: external_exports.number().int().nonnegative()
20105
+ });
20106
+
20107
+ // ../../packages/contracts/src/orgs.ts
20108
+ var Org = external_exports.object({
20109
+ id: external_exports.string(),
20110
+ name: external_exports.string(),
20111
+ role: Role,
20112
+ active: external_exports.boolean()
20113
+ });
20114
+ var OrgListResponse = external_exports.object({ data: external_exports.array(Org) });
20115
+ var OrgResponse = external_exports.object({ data: Org });
20116
+ var CreateOrgRequest = external_exports.object({ name: external_exports.string().trim().min(1).max(80) });
20117
+ var SetActiveOrgRequest = external_exports.object({ organizationId: external_exports.string().min(1).max(128) });
20118
+ var OnboardingState = external_exports.object({ hidden: external_exports.boolean() });
20119
+ var OnboardingResponse = external_exports.object({ data: OnboardingState });
20120
+
20121
+ // ../../packages/contracts/src/members.ts
20122
+ var MemberRole = external_exports.enum(["viewer", "auditor", "administrator"]);
20123
+ var Member = external_exports.object({
20124
+ userId: external_exports.string(),
20125
+ name: external_exports.string().nullable(),
20126
+ email: external_exports.string().nullable(),
20127
+ role: Role,
20128
+ joinedAt: external_exports.string().nullable(),
20129
+ /** The caller. */
20130
+ you: external_exports.boolean()
20131
+ });
20132
+ var Seats = external_exports.object({ used: external_exports.number().int().nonnegative(), limit: external_exports.number().int().nonnegative().nullable() });
20133
+ var MemberListResponse = external_exports.object({ data: external_exports.array(Member), seats: Seats.nullable() });
20134
+ var MemberResponse = external_exports.object({ data: Member });
20135
+ var UpdateMemberRequest = external_exports.object({ role: MemberRole });
20136
+ var Invitation = external_exports.object({
20137
+ id: external_exports.string(),
20138
+ email: external_exports.string(),
20139
+ role: MemberRole,
20140
+ invitedBy: external_exports.object({ userId: external_exports.string(), name: external_exports.string().nullable(), email: external_exports.string().nullable() }),
20141
+ expiresAt: external_exports.string()
20142
+ });
20143
+ var InvitationListResponse = external_exports.object({ data: external_exports.array(Invitation) });
20144
+ var InvitationResponse = external_exports.object({ data: Invitation });
20145
+ var CreateInvitationRequest = external_exports.object({
20146
+ email: external_exports.string().trim().toLowerCase().pipe(external_exports.email().max(254)),
20147
+ role: MemberRole
20148
+ });
20149
+
20034
20150
  // ../../packages/contracts/src/index.ts
20035
20151
  var ErrorBody = external_exports.object({
20036
20152
  error: external_exports.object({
@@ -20070,16 +20186,27 @@ var EXPIRY_MARGIN_MS = 6e4;
20070
20186
  var LOCK_POLL_MS = 250;
20071
20187
  var LOCK_WAIT_MS = 1e4;
20072
20188
  var LOCK_STALE_MS = 3e4;
20073
- function authConfig(env) {
20189
+ var PRODUCTION_API = "https://api.teemops.com";
20190
+ var STAGING_AUDIENCE = "https://api-staging.teemops.com";
20191
+ var isProduction = (apiUrl) => {
20192
+ try {
20193
+ return new URL(apiUrl).origin === PRODUCTION_API;
20194
+ } catch {
20195
+ return false;
20196
+ }
20197
+ };
20198
+ function authConfig(env, apiUrl = PRODUCTION_API) {
20074
20199
  return {
20075
20200
  issuer: (env.TEEMOPS_AUTH_ISSUER || "https://auth.teem.cloud/api/auth").replace(/\/$/, ""),
20076
20201
  clientId: env.TEEMOPS_CLIENT_ID ?? DEFAULT_CLI_CLIENT_ID,
20077
- audience: env.TEEMOPS_API_AUDIENCE || "https://api.teemops.com"
20202
+ audience: env.TEEMOPS_API_AUDIENCE || (isProduction(apiUrl) ? PRODUCTION_API : STAGING_AUDIENCE)
20078
20203
  };
20079
20204
  }
20080
20205
  var Credentials = external_exports.object({
20081
20206
  issuer: external_exports.string(),
20082
20207
  clientId: external_exports.string(),
20208
+ /** The API the tokens are for. Files saved before stages had their own audience are production's. */
20209
+ audience: external_exports.string().default(PRODUCTION_API),
20083
20210
  accessToken: external_exports.string(),
20084
20211
  refreshToken: external_exports.string(),
20085
20212
  expiresAt: external_exports.string()
@@ -20183,7 +20310,7 @@ async function login(io, cfg) {
20183
20310
  });
20184
20311
  const token = TokenResponse.safeParse(res.body);
20185
20312
  if (res.ok && token.success) {
20186
- const creds = toCredentials(token.data, { issuer: cfg.issuer, clientId: cfg.clientId });
20313
+ const creds = toCredentials(token.data, { issuer: cfg.issuer, clientId: cfg.clientId, audience: cfg.audience });
20187
20314
  await saveCredentials(io, creds);
20188
20315
  return { signedIn: true, expiresAt: creds.expiresAt };
20189
20316
  }
@@ -20220,6 +20347,13 @@ async function tokenSource(io, cfg) {
20220
20347
  if (fixed) return { token: async () => fixed, refresh: async () => null };
20221
20348
  let creds = await readCredentials(io);
20222
20349
  if (!creds) throw new CliError("unauthenticated", "Not signed in \u2014 run `teemops login` or set TEEMOPS_TOKEN", ExitCode.auth);
20350
+ if (creds.audience !== cfg.audience) {
20351
+ throw new CliError(
20352
+ "wrong_stage",
20353
+ `Signed in for ${creds.audience}, but this API takes tokens for ${cfg.audience} \u2014 run \`teemops login\` with the same API URL`,
20354
+ ExitCode.auth
20355
+ );
20356
+ }
20223
20357
  let refreshed = false;
20224
20358
  const fresh = (c) => Date.parse(c.expiresAt) - Date.now() >= EXPIRY_MARGIN_MS;
20225
20359
  const refresh = () => withLock(io, async () => {
@@ -20312,14 +20446,17 @@ var billingCommands = {
20312
20446
  auth: true,
20313
20447
  flags: {
20314
20448
  plan: str("Plan: solo | team | business"),
20315
- currency: str("Currency: usd | nzd | aud (default usd)")
20449
+ country: str("Where the organization is based: nz | au | other. Decides the currency and GST"),
20450
+ currency: str("nz: nzd. au: aud (default) or usd. other: usd")
20316
20451
  },
20317
20452
  run: (ctx) => {
20318
- const parsed = CheckoutRequest.safeParse({ plan: ctx.flags.plan, currency: ctx.flags.currency ?? "usd" });
20453
+ const country = BillingCountry.safeParse(ctx.flags.country);
20454
+ const currency = ctx.flags.currency ?? (country.success ? COUNTRY_CURRENCIES[country.data][0] : void 0);
20455
+ const parsed = CheckoutRequest.safeParse({ plan: ctx.flags.plan, country: ctx.flags.country, currency });
20319
20456
  if (!parsed.success) {
20320
20457
  const flag = String(parsed.error.issues[0]?.path[0]);
20321
- const options = flag === "plan" ? "solo | team | business" : Currency.options.join(" | ");
20322
- throw new CliError("usage", `Missing or invalid --${flag}: use ${options}`, ExitCode.usage);
20458
+ const options = flag === "plan" ? "solo | team | business" : flag === "country" || !country.success ? BillingCountry.options.join(" | ") : `${COUNTRY_CURRENCIES[country.data].join(" | ")} with --country ${country.data}`;
20459
+ throw new CliError("usage", `Missing or invalid --${flag === "currency" && !country.success ? "country" : flag}: use ${options}`, ExitCode.usage);
20323
20460
  }
20324
20461
  return session(ctx, "/v1/billing/checkout", parsed.data);
20325
20462
  },
@@ -20367,17 +20504,22 @@ function insightsArgs(flags) {
20367
20504
  throw new CliError("usage", `Missing or invalid --${String(parsed.error.issues[0]?.path[0] ?? "input")}`, ExitCode.usage);
20368
20505
  }
20369
20506
  var dash = (v, suffix = "") => v === null ? "\u2014" : `${v}${suffix}`;
20370
- function renderInsights(d) {
20507
+ var LOCKED_HINT = "Results are locked. Choose a plan: teemops billing checkout --plan <solo|team|business> --country <nz|au|other>";
20508
+ var LOCKED = "locked";
20509
+ var nothing = (locked) => locked ? LOCKED : "\u2014";
20510
+ function renderInsights({ data: d, locked }) {
20371
20511
  const score = d.score === null ? "Not scored yet" : `${d.score}${d.band ? ` (${d.band.replace("_", " ")})` : ""}`;
20372
20512
  const k = d.kpis;
20373
20513
  return [
20374
20514
  plain(`Health score: ${score} \xB7 ${d.period} \xB7 ${d.account ? `account ${d.account}` : "all accounts"}`),
20375
20515
  "",
20376
- table(["FACTOR", "WEIGHT", "SCORE", "DETAIL"], d.factors.map((f) => [f.key, `${f.weight}%`, dash(f.score), f.detail])),
20516
+ d.factors ? table(["FACTOR", "WEIGHT", "SCORE", "DETAIL"], d.factors.map((f) => [f.key, `${f.weight}%`, dash(f.score), f.detail])) : `Factors: ${LOCKED}`,
20377
20517
  "",
20378
- `Open now ${k.openNow} (was ${k.openAtStart}) \xB7 raised ${k.raised} \xB7 resolved ${k.resolved} \xB7 remediation ${dash(k.remediationRate, "%")}`,
20379
- `Coverage ${dash(k.coverage, "%")} \xB7 median days to fix a critical ${dash(k.medianDaysToFixCritical)}`,
20380
- ...d.frameworks.length === 0 ? [] : [
20518
+ ...k ? [
20519
+ `Open now ${k.openNow} (was ${k.openAtStart}) \xB7 raised ${k.raised} \xB7 resolved ${k.resolved} \xB7 remediation ${dash(k.remediationRate, "%")}`,
20520
+ `Coverage ${dash(k.coverage, "%")} \xB7 median days to fix a critical ${dash(k.medianDaysToFixCritical)}`
20521
+ ] : [`Key numbers: ${LOCKED}`, `Open by severity: ${[...SEVERITIES].reverse().map((s) => `${s} ${d.openBy.severity[s] ?? 0}`).join(" \xB7 ")}`],
20522
+ ...!d.frameworks ? ["", `Frameworks: ${LOCKED}`] : !d.frameworks.length ? [] : [
20381
20523
  "",
20382
20524
  table(
20383
20525
  ["FRAMEWORK", "SCORE", "PASSING", "OPEN C/H/M/L"],
@@ -20388,7 +20530,8 @@ function renderInsights(d) {
20388
20530
  `${f.open.critical}/${f.open.high}/${f.open.medium}/${f.open.low}`
20389
20531
  ])
20390
20532
  )
20391
- ]
20533
+ ],
20534
+ ...locked ? ["", LOCKED_HINT] : []
20392
20535
  ].join("\n");
20393
20536
  }
20394
20537
  var SEVERITIES = Severity.options;
@@ -20537,7 +20680,7 @@ var commands = {
20537
20680
  account ${plain(d.accountRef)}` : d.status
20538
20681
  },
20539
20682
  remove: {
20540
- summary: "Disconnect a cloud account",
20683
+ summary: "Disconnect a cloud account (its findings and scans are deleted after 7 days unless it is reconnected)",
20541
20684
  auth: true,
20542
20685
  flags: { id: str2("Account id from `accounts list`") },
20543
20686
  run: async ({ api, flags }) => {
@@ -20648,27 +20791,38 @@ ${showOnce(d.credential)}`
20648
20791
  render: (d) => [
20649
20792
  d.data.length === 0 ? "No findings match." : table(
20650
20793
  ["SEVERITY", "TITLE", "ACCOUNT", "REGION", "RESOURCE", "ID"],
20651
- d.data.map((f) => [upper(f.severity), f.title, f.accountId, f.region, `${f.resourceType} ${f.resourceId}`, f.id])
20794
+ d.data.map((f) => [
20795
+ upper(f.severity),
20796
+ f.title,
20797
+ f.accountId,
20798
+ f.region ?? nothing(d.locked),
20799
+ f.resourceId ? `${f.resourceType} ${f.resourceId}` : nothing(d.locked),
20800
+ f.id
20801
+ ])
20652
20802
  ),
20653
- ...d.nextCursor ? [`More: --cursor ${d.nextCursor}`] : []
20803
+ ...d.nextCursor ? [`More: --cursor ${d.nextCursor}`] : [],
20804
+ ...d.locked ? ["", LOCKED_HINT] : []
20654
20805
  ].join("\n")
20655
20806
  },
20656
20807
  get: {
20657
20808
  summary: "Show one finding with how to fix it",
20658
20809
  auth: true,
20659
20810
  flags: { id: str2("Finding id from `findings list`") },
20811
+ envelope: true,
20812
+ // `locked` is beside `data`.
20660
20813
  run: async ({ api, flags }) => {
20661
20814
  const parsed = FindingsGetArgs.safeParse({ id: flags.id });
20662
20815
  if (!parsed.success) throw new CliError("usage", "Missing --id", ExitCode.usage);
20663
- return FindingResponse.parse(await api(`/v1/findings/${encodeURIComponent(parsed.data.id)}`)).data;
20816
+ return FindingResponse.parse(await api(`/v1/findings/${encodeURIComponent(parsed.data.id)}`));
20664
20817
  },
20665
- render: (f) => [
20818
+ render: ({ data: f, locked }) => [
20666
20819
  `${upper(f.severity)} ${f.title}`,
20667
- `Resource: ${f.resourceType} ${f.resourceId} (${f.accountId}, ${f.region})`,
20820
+ `Resource: ${f.resourceId ? `${f.resourceType} ${f.resourceId} (${f.accountId}, ${f.region})` : `${nothing(locked)} (${f.accountId})`}`,
20668
20821
  `Rule: ${f.ruleId}`,
20669
20822
  `Status: ${f.status}, first seen ${day(f.firstSeenAt)}`,
20670
20823
  "",
20671
- `Fix: ${f.remediation.text}`
20824
+ `Fix: ${f.remediation ? f.remediation.text : nothing(locked)}`,
20825
+ ...locked ? ["", LOCKED_HINT] : []
20672
20826
  ].map(plain).join("\n")
20673
20827
  }
20674
20828
  }
@@ -20680,13 +20834,34 @@ ${showOnce(d.credential)}`
20680
20834
  period: str2("Period: 30d | 90d | 365d (default 30d)"),
20681
20835
  account: str2("Cloud account id, the same value as `findings list --account` (default: all accounts)")
20682
20836
  },
20837
+ envelope: true,
20838
+ // `locked` is beside `data`.
20683
20839
  run: async ({ api, flags }) => {
20684
20840
  const args = insightsArgs(flags);
20685
20841
  const query = new URLSearchParams({ period: args.period ?? "30d", ...args.account ? { account: args.account } : {} });
20686
- return InsightsResponse.parse(await api(`/v1/insights?${query}`)).data;
20842
+ return InsightsResponse.parse(await api(`/v1/insights?${query}`));
20687
20843
  },
20688
20844
  render: renderInsights
20689
20845
  },
20846
+ members: {
20847
+ summary: "List your organization's members",
20848
+ subcommands: {
20849
+ list: {
20850
+ summary: "List members with their role and when they joined, and the seats used (owners and administrators)",
20851
+ auth: true,
20852
+ envelope: true,
20853
+ // `seats` is beside `data`.
20854
+ run: async ({ api }) => MemberListResponse.parse(await api("/v1/members")),
20855
+ render: ({ data, seats }) => [
20856
+ table(
20857
+ ["NAME", "EMAIL", "ROLE", "JOINED"],
20858
+ data.map((m) => [`${m.name ?? "\u2014"}${m.you ? " (you)" : ""}`, m.email ?? "\u2014", m.role, m.joinedAt ? day(m.joinedAt) : "\u2014"])
20859
+ ),
20860
+ ...seats ? ["", seats.limit === null ? `${seats.used} users` : `${seats.used} of ${seats.limit} users`] : []
20861
+ ].join("\n")
20862
+ }
20863
+ }
20864
+ },
20690
20865
  scan: {
20691
20866
  summary: "Scan a connected cloud account and wait for the result",
20692
20867
  subcommands: {
@@ -20793,10 +20968,10 @@ function describe3() {
20793
20968
  TEEMOPS_API_URL: "API base URL, default https://api.teemops.com",
20794
20969
  TEEMOPS_AUTH_ISSUER: "Sign-in issuer, default https://auth.teem.cloud/api/auth",
20795
20970
  TEEMOPS_CLIENT_ID: "Client ID of the teemops CLI at the issuer",
20796
- TEEMOPS_API_AUDIENCE: "Audience (resource) of tokens, default https://api.teemops.com"
20971
+ TEEMOPS_API_AUDIENCE: "Audience (resource) of tokens. Default: https://api.teemops.com for the production API, https://api-staging.teemops.com for any other API URL"
20797
20972
  },
20798
20973
  exitCodes: ExitCode,
20799
- exitCodeNotes: { conflict: "409 conflict (billing commands)" },
20974
+ exitCodeNotes: { conflict: "409 conflict: no plan, over a plan limit, or a billing conflict" },
20800
20975
  flags: globalFlags,
20801
20976
  commands: Object.fromEntries(
20802
20977
  Object.entries(commands).map(([k, c]) => [
@@ -20848,9 +21023,9 @@ async function main(io) {
20848
21023
  throw new CliError("usage", `--${k} is not valid for "${positionals.slice(0, isGroup(entry) ? 2 : 1).join(" ")}"`, ExitCode.usage);
20849
21024
  }
20850
21025
  }
20851
- const cfg = authConfig(io.env);
20852
- const tokens = command.auth ? await tokenSource(io, cfg) : null;
20853
21026
  const baseUrl = String(values["api-url"] || io.env.TEEMOPS_API_URL || "https://api.teemops.com").replace(/\/$/, "");
21027
+ const cfg = authConfig(io.env, baseUrl);
21028
+ const tokens = command.auth ? await tokenSource(io, cfg) : null;
20854
21029
  const api = async (path, init = {}) => {
20855
21030
  const send = async (token) => {
20856
21031
  const headers = {};
@@ -20879,7 +21054,7 @@ async function main(io) {
20879
21054
  const err = ErrorBody.safeParse(parsed);
20880
21055
  const message = err.success ? err.data.error.message : `HTTP ${res.status}`;
20881
21056
  const code = err.success ? err.data.error.code : "http_error";
20882
- const exit = res.status === 401 || res.status === 403 ? ExitCode.auth : res.status === 404 ? ExitCode.notFound : ExitCode.error;
21057
+ const exit = res.status === 401 || res.status === 403 ? ExitCode.auth : res.status === 404 ? ExitCode.notFound : res.status === 409 ? ExitCode.conflict : ExitCode.error;
20883
21058
  throw new CliError(code, message, exit, res.status);
20884
21059
  };
20885
21060
  const result = await command.run({ api, flags, io, cfg });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teemops/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Agent-first CLI for teemops: cloud security scanning, findings and insights",
5
5
  "license": "GPL-3.0-only",
6
6
  "repository": {