@ycadence/cli 0.2.1 → 0.4.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 +22 -3
  2. package/dist/bin.js +124 -8
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -30,7 +30,27 @@ npm install -g @yarlisaisolutions/ycadence-cli
30
30
  The package installs two commands that behave the same: `ycadence`, and `devpulse`,
31
31
  the name from before the rename.
32
32
 
33
- ## Configure
33
+ ## Sign in
34
+
35
+ **Behind Google IAP** (the Yarlis instances): open the web app, go to **Settings →
36
+ Account → Command line**, click **Copy command**, and run it. It looks like this:
37
+
38
+ ```sh
39
+ ycadence login --setup ycs1.…
40
+ ```
41
+
42
+ It prints a code and a google.com/device link. Approve it with your work account and the
43
+ CLI finishes by itself: the instance issues this machine a personal token, and everything
44
+ secret goes into your OS keychain. Then:
45
+
46
+ ```sh
47
+ ycadence doctor
48
+ ```
49
+
50
+ The full walkthrough, troubleshooting and the admin setup are in
51
+ [docs/cli/getting-started.md](https://github.com/YarlisAISolutions/dev-pulse/blob/main/docs/cli/getting-started.md).
52
+
53
+ **Anywhere else**, sign in with a personal token from **Settings → Account → API tokens**:
34
54
 
35
55
  ```sh
36
56
  ycadence login --url https://ycadence.example.com --api-key-stdin < key.txt
@@ -41,8 +61,7 @@ ycadence doctor # checks configuration and connecti
41
61
  Profiles live in `~/.config/ycadence/config.json` (`$XDG_CONFIG_HOME` aware); switch with
42
62
  `--profile <name>` or `ycadence config use <name>`. The API key is kept in the OS keychain
43
63
  (macOS Keychain, or the Secret Service through `secret-tool`), else in a `credentials.json`
44
- with mode 0600. For an instance behind Google IAP, `ycadence login --oauth-client-id …
45
- --iap-audience …` signs in with Google and refreshes the identity token itself.
64
+ with mode 0600.
46
65
 
47
66
  Environment variables still win, so CI needs nothing else:
48
67
 
package/dist/bin.js CHANGED
@@ -168,7 +168,7 @@ async function* paginate(fetchPage, options = {}) {
168
168
  }
169
169
 
170
170
  // ../sdk/src/version.ts
171
- var SDK_VERSION = "0.2.0";
171
+ var SDK_VERSION = "0.3.0";
172
172
 
173
173
  // ../sdk/src/client.ts
174
174
  var DEFAULT_RETRY = { retries: 2, baseDelayMs: 250, maxDelayMs: 8000 };
@@ -535,6 +535,7 @@ var PROFILE_KEYS = [
535
535
  "project",
536
536
  "format",
537
537
  "oauthClientId",
538
+ "oauthFlow",
538
539
  "iapAudience"
539
540
  ];
540
541
  var PROFILE_KEY_HELP = {
@@ -543,6 +544,7 @@ var PROFILE_KEY_HELP = {
543
544
  project: "Default project id (or identifier, once the server lists projects)",
544
545
  format: "Default output format: table, json or ndjson",
545
546
  oauthClientId: "Desktop OAuth client id used by `ycadence login` for IAP",
547
+ oauthFlow: "How `ycadence login` signs in to Google: device or loopback",
546
548
  iapAudience: "The IAP OAuth client id; the audience of the identity token"
547
549
  };
548
550
  function configDirectory(env) {
@@ -1683,6 +1685,17 @@ function closingState(states) {
1683
1685
  }
1684
1686
  return done;
1685
1687
  }
1688
+ async function resolveTypeId(client, projectId, value) {
1689
+ if (isUuid(value))
1690
+ return value;
1691
+ const { types: types2 } = await client.listTypes(projectId);
1692
+ const named = types2.filter((type) => typeof type.id === "string" && typeof type.name === "string");
1693
+ const match = named.find((type) => type.name.toLowerCase() === value.toLowerCase());
1694
+ if (match === undefined) {
1695
+ throw new CliError(`No work item type "${value}" in this project. Types: ${named.map((type) => type.name).join(", ")}.`, "validation_failed", EXIT.validation);
1696
+ }
1697
+ return match.id;
1698
+ }
1686
1699
  var KEY_SCAN_LIMIT = 2000;
1687
1700
  async function resolveWorkItemId(client, value, projectId) {
1688
1701
  if (isUuid(value))
@@ -1859,6 +1872,40 @@ function completionScript(root, shell) {
1859
1872
  return fish(root);
1860
1873
  }
1861
1874
 
1875
+ // src/handlers/auth.ts
1876
+ import { hostname } from "node:os";
1877
+
1878
+ // ../core/src/cli-setup.ts
1879
+ var SETUP_CODE_PREFIX = "ycs1.";
1880
+ function decodeSetupCode(code) {
1881
+ const trimmed = code.trim();
1882
+ if (!trimmed.startsWith(SETUP_CODE_PREFIX))
1883
+ return null;
1884
+ let parsed;
1885
+ try {
1886
+ parsed = JSON.parse(Buffer.from(trimmed.slice(SETUP_CODE_PREFIX.length), "base64url").toString("utf8"));
1887
+ } catch {
1888
+ return null;
1889
+ }
1890
+ if (typeof parsed !== "object" || parsed === null)
1891
+ return null;
1892
+ const { u, c, s, f } = parsed;
1893
+ if (typeof u !== "string" || typeof c !== "string" || typeof s !== "string")
1894
+ return null;
1895
+ if (f !== "device" && f !== "loopback")
1896
+ return null;
1897
+ try {
1898
+ const url = new URL(u);
1899
+ if (url.protocol !== "https:" && url.hostname !== "localhost")
1900
+ return null;
1901
+ } catch {
1902
+ return null;
1903
+ }
1904
+ if (c === "" || s === "")
1905
+ return null;
1906
+ return { url: u.replace(/\/+$/, ""), oauthClientId: c, oauthClientSecret: s, flow: f };
1907
+ }
1908
+
1862
1909
  // src/handlers/auth.ts
1863
1910
  function str(value) {
1864
1911
  return typeof value === "string" ? value : undefined;
@@ -1869,11 +1916,44 @@ function keyHint(key) {
1869
1916
  async function storedKey(context) {
1870
1917
  return readEnv("API_KEY", context.env) ?? await context.secrets.get(secretAccount.apiKey(context.config.profile));
1871
1918
  }
1919
+ async function requestCliToken(context, url, idToken) {
1920
+ let response;
1921
+ try {
1922
+ response = await context.fetch(`${url}/api/auth/iap/cli-token`, {
1923
+ method: "POST",
1924
+ headers: {
1925
+ Authorization: `Bearer ${idToken}`,
1926
+ "Content-Type": "application/json",
1927
+ Accept: "application/json"
1928
+ },
1929
+ body: JSON.stringify({ organization: context.config.org, host: hostname() })
1930
+ });
1931
+ } catch (error) {
1932
+ throw new CliError(`Could not reach ${new URL(url).host}: ${error instanceof Error ? error.message : "error"}`, "network_error", EXIT.tempfail);
1933
+ }
1934
+ if (response.status === 404)
1935
+ return null;
1936
+ const body = await response.json().catch(() => ({}));
1937
+ if (!response.ok || body.token === undefined || body.organization === undefined) {
1938
+ throw new CliError(`Signed in, but no token was issued: ${body.error ?? `HTTP ${response.status}`}`, body.code ?? "token_refused");
1939
+ }
1940
+ return { token: body.token, organization: body.organization };
1941
+ }
1872
1942
  var loginHandler = async (context) => {
1873
1943
  const profile = context.config.profile;
1874
1944
  assertProfileName(profile);
1875
1945
  const file = await context.configStore.load();
1876
1946
  const stored = { ...file.profiles[profile] ?? {} };
1947
+ const setupFlag = str(context.flags.setup);
1948
+ const setup = setupFlag === undefined ? undefined : decodeSetupCode(setupFlag);
1949
+ if (setup === null) {
1950
+ throw new UsageError("That setup code could not be read. Copy the command again from Settings → Account → Command line.");
1951
+ }
1952
+ if (setup !== undefined) {
1953
+ stored.url = setup.url;
1954
+ stored.oauthClientId = setup.oauthClientId;
1955
+ stored.oauthFlow = setup.flow;
1956
+ }
1877
1957
  const url = str(context.flags.url);
1878
1958
  if (url !== undefined) {
1879
1959
  try {
@@ -1907,12 +1987,12 @@ var loginHandler = async (context) => {
1907
1987
  } else if (clientId === undefined) {
1908
1988
  context.notice("No OAuth client is configured, so the IAP sign-in is skipped. That is right for an instance that is not behind IAP. For one that is, a person must first create the Desktop OAuth client (see docs/cli/work-management.md), then run `ycadence login --oauth-client-id <id> --iap-audience <IAP client id>`.");
1909
1989
  } else {
1910
- const clientSecret = context.env.YCADENCE_OAUTH_CLIENT_SECRET || await context.secrets.get(secretAccount.oauthClientSecret(profile));
1990
+ const clientSecret = context.env.YCADENCE_OAUTH_CLIENT_SECRET || setup?.oauthClientSecret || await context.secrets.get(secretAccount.oauthClientSecret(profile));
1911
1991
  if (clientSecret === undefined || clientSecret === "") {
1912
- throw new ConfigError("The OAuth client secret is missing. Set YCADENCE_OAUTH_CLIENT_SECRET for this login; it is then kept in the credential store.");
1992
+ throw new ConfigError("The OAuth client secret is missing. Copy the sign-in command from Settings → Account → Command line, or set YCADENCE_OAUTH_CLIENT_SECRET for this login; it is then kept in the credential store.");
1913
1993
  }
1914
1994
  const client = oauthClientFrom({ clientId, clientSecret, env: context.env });
1915
- const tokens = context.flags.device === true ? await deviceLogin({
1995
+ const tokens = context.flags.device === true || stored.oauthFlow === "device" ? await deviceLogin({
1916
1996
  client,
1917
1997
  fetch: context.fetch,
1918
1998
  print: (message) => context.notice(message),
@@ -1943,6 +2023,19 @@ var loginHandler = async (context) => {
1943
2023
  await context.secrets.set(secretAccount.identityToken(profile), JSON.stringify({ token: identity.idToken, expiresAt: identity.expiresAt }));
1944
2024
  }
1945
2025
  signedIn = true;
2026
+ if (apiKey === undefined && identity !== undefined && stored.url !== undefined) {
2027
+ const issued = await requestCliToken(context, stored.url, identity.idToken);
2028
+ if (issued === null) {
2029
+ context.notice("This server cannot issue CLI tokens yet. Create a personal token under Settings → Account → API tokens, then run `ycadence login --api-key-stdin` with it on stdin.");
2030
+ } else {
2031
+ apiKey = issued.token;
2032
+ if (stored.org === undefined) {
2033
+ stored.org = issued.organization;
2034
+ file.profiles[profile] = stored;
2035
+ await context.configStore.save(file);
2036
+ }
2037
+ }
2038
+ }
1946
2039
  }
1947
2040
  if (apiKey !== undefined)
1948
2041
  await context.secrets.set(secretAccount.apiKey(profile), apiKey);
@@ -2144,7 +2237,7 @@ var configPathHandler = async (context) => {
2144
2237
  };
2145
2238
 
2146
2239
  // src/version.ts
2147
- var CLI_VERSION = "0.2.1";
2240
+ var CLI_VERSION = "0.4.0";
2148
2241
 
2149
2242
  // src/handlers/doctor.ts
2150
2243
  function legacyNote(name, env) {
@@ -2545,6 +2638,12 @@ var WORK_FIELD_FLAGS = {
2545
2638
  placeholder: "<name|id>",
2546
2639
  description: "State, by name or id"
2547
2640
  },
2641
+ type: {
2642
+ name: "type",
2643
+ type: "string",
2644
+ placeholder: "<name|id>",
2645
+ description: "Work item type, by name or id"
2646
+ },
2548
2647
  parent: {
2549
2648
  name: "parent",
2550
2649
  type: "string",
@@ -2677,6 +2776,9 @@ var createWork = async (context) => {
2677
2776
  if (stateValue !== undefined) {
2678
2777
  body.stateId = isUuid(stateValue) ? stateValue : findState(await listStates(client, projectId), stateValue).id;
2679
2778
  }
2779
+ const typeValue = str6(context.flags.type);
2780
+ if (typeValue !== undefined)
2781
+ body.typeId = await resolveTypeId(client, projectId, typeValue);
2680
2782
  const assignees = list(context.flags.assignee);
2681
2783
  for (const assignee of assignees) {
2682
2784
  if (!isUuid(assignee)) {
@@ -2725,6 +2827,10 @@ var updateWork = async (context) => {
2725
2827
  if (stateValue !== undefined) {
2726
2828
  body.stateId = isUuid(stateValue) ? stateValue : findState(await listStates(client, await context.projectId()), stateValue).id;
2727
2829
  }
2830
+ const typeValue = str6(context.flags.type);
2831
+ if (typeValue !== undefined) {
2832
+ body.typeId = isUuid(typeValue) ? typeValue : await resolveTypeId(client, await context.projectId(), typeValue);
2833
+ }
2728
2834
  if (context.flags["no-parent"] === true)
2729
2835
  body.parentId = null;
2730
2836
  const parent = str6(context.flags.parent);
@@ -2737,7 +2843,7 @@ var updateWork = async (context) => {
2737
2843
  if (targetDate !== undefined)
2738
2844
  body.targetDate = targetDate;
2739
2845
  if (Object.keys(body).length === 0) {
2740
- throw new UsageError("Nothing to update. Pass at least one of --title, --description, --priority, --state, --parent, --start-date, --target-date.");
2846
+ throw new UsageError("Nothing to update. Pass at least one of --title, --description, --priority, --state, --type, --parent, --start-date, --target-date.");
2741
2847
  }
2742
2848
  const version = context.flags["expect-version"];
2743
2849
  const updated = await client.updateWorkItem(id, body, context.mutationOptions(version === undefined ? {} : { expectedVersion: version }));
@@ -2812,6 +2918,12 @@ var legacyProjectArg = {
2812
2918
  description: "Project id (the older positional form of --project)"
2813
2919
  };
2814
2920
  var LOGIN_FLAGS = [
2921
+ {
2922
+ name: "setup",
2923
+ type: "string",
2924
+ placeholder: "<code>",
2925
+ description: "Setup code from Settings → Account → Command line (URL, OAuth client, flow)"
2926
+ },
2815
2927
  { name: "url", type: "string", placeholder: "<url>", description: "The instance URL" },
2816
2928
  { name: "api-key-stdin", type: "boolean", description: "Read the API key from stdin" },
2817
2929
  { name: "device", type: "boolean", description: "Use the device flow (no local browser)" },
@@ -2924,6 +3036,7 @@ var TREE = {
2924
3036
  WORK_FIELD_FLAGS.descriptionFile,
2925
3037
  WORK_FIELD_FLAGS.priority,
2926
3038
  WORK_FIELD_FLAGS.state,
3039
+ WORK_FIELD_FLAGS.type,
2927
3040
  {
2928
3041
  name: "assignee",
2929
3042
  type: "string",
@@ -2944,7 +3057,8 @@ var TREE = {
2944
3057
  ],
2945
3058
  examples: [
2946
3059
  'ycadence work create --project <projectId> --title "Fix login redirect" --priority high',
2947
- 'ycadence work create --title "Spike" --description-file notes.md --idempotency-key spike-1'
3060
+ 'ycadence work create --title "Spike" --description-file notes.md --idempotency-key spike-1',
3061
+ 'ycadence work create --project <projectId> --title "Pick a billing provider" --type Decision --label decisions'
2948
3062
  ],
2949
3063
  run: createWork
2950
3064
  },
@@ -2959,6 +3073,7 @@ var TREE = {
2959
3073
  WORK_FIELD_FLAGS.descriptionFile,
2960
3074
  WORK_FIELD_FLAGS.priority,
2961
3075
  WORK_FIELD_FLAGS.state,
3076
+ WORK_FIELD_FLAGS.type,
2962
3077
  WORK_FIELD_FLAGS.parent,
2963
3078
  { name: "no-parent", type: "boolean", description: "Remove the parent" },
2964
3079
  WORK_FIELD_FLAGS.startDate,
@@ -3301,10 +3416,11 @@ var TREE = {
3301
3416
  {
3302
3417
  name: "login",
3303
3418
  summary: "Save a profile, sign in for IAP, and store an API key",
3304
- description: "Signs in with Google (loopback, or --device) against the configured Desktop OAuth client for an IAP identity token, when one is configured. The API key is read from stdin and kept in the OS keychain.",
3419
+ description: "Behind IAP, run the command from Settings → Account → Command line: `--setup` carries the URL and OAuth client, the device flow signs you in to Google, and the instance issues this machine a personal token. Elsewhere, pass --url and an API key on stdin. Credentials are kept in the OS keychain.",
3305
3420
  needsClient: false,
3306
3421
  flags: LOGIN_FLAGS,
3307
3422
  examples: [
3423
+ "ycadence login --setup ycs1.…",
3308
3424
  "ycadence login --url https://ycadence.example.com --api-key-stdin < key.txt",
3309
3425
  "ycadence login --profile yarlis --oauth-client-id <desktop client id> --iap-audience <iap client id>"
3310
3426
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ycadence/cli",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "YCadence from the command line: work items, states, cycles and imports.",
5
5
  "keywords": [
6
6
  "ycadence",