@gavana.ai/cli 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog — @gavana.ai/cli
2
2
 
3
+ ## 0.3.3
4
+
5
+ ### Fixed
6
+
7
+ - **`auth login` stops handing out an instruction it already carried out.** It
8
+ opened the browser and then printed a line that read as a request to open the
9
+ URL, so a coding agent running the command opened it a second time and the
10
+ user was left on two tabs of one authorization request — same `client_id`,
11
+ same `state`, same `code_challenge`. The browser path now says the URL is
12
+ already open and prints it only to be shown. The one case that still asks is a
13
+ machine where the open actually failed.
14
+ - **The login wait is announced, and settable.** It blocked for an unannounced
15
+ 180 seconds, longer than the command timeout a caller is likely to assume, so
16
+ an agent-run login was killed mid-sign-in and took the local callback server —
17
+ and the user's open tab — with it. The wait is now printed, `--timeout
18
+ SECONDS` sets it, and the timeout error says the open page is dead and that
19
+ re-running prints a new URL.
20
+ - **`canvas_validate` no longer excuses a run that referenced nothing.** The
21
+ product-fidelity review began by filtering to outputs that already carried
22
+ reference evidence, so a prompt-only run emptied that filter and was graded
23
+ `not-applicable` — the worst case was the one case it excused. An imported
24
+ product reference that no generated output uses now raises
25
+ `unused_product_reference` and reports `needs-review`, naming both the ignored
26
+ product and the outputs that ignored it. A canvas holding an imported product
27
+ and no generated output stays `not-applicable`.
28
+
29
+ ## 0.3.2
30
+
31
+ ### Changed
32
+
33
+ - **`mcp install` says what is left to do.** It answered `installed: true` and
34
+ stopped, so agents reported the tools as ready while the server sat
35
+ unauthenticated. The result now carries `authenticated: false` and the
36
+ remaining steps: restart the client, sign that server in (its login is its
37
+ own, separate from the CLI's), and confirm with a real read rather than a
38
+ status line.
39
+
40
+ ### Fixed
41
+
42
+ - **`bin/gavana.mjs` ships executable.** It was published as `0644` while its
43
+ `craftboard` twin was `0755`, so the `gavana` command depended on npm
44
+ repairing the mode while linking. Where it did not, the first command a new
45
+ user ran answered `permission denied` (exit 126).
46
+
3
47
  ## 0.3.1
4
48
 
5
49
  ### Fixed
package/bin/gavana.mjs CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gavana.ai/cli",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "JSON-first command-line client for the Gavana Canvas API",
5
5
  "type": "module",
6
6
  "bin": {
@@ -324,9 +324,65 @@ function graphFindings(canvas, validationOptions = {}) {
324
324
  }
325
325
  }
326
326
  }
327
+
328
+ // Canvas-level, because the failure is an absence: no single output is
329
+ // malformed, they simply all ignored the product the person imported.
330
+ // Reported against the outputs so the standard scoping decides whether it
331
+ // blocks — this task's work is pinned, somebody else's older run is not.
332
+ const unusedProducts = unusedProductReferenceNodes(canvas);
333
+ if (unusedProducts.length) {
334
+ const outputs = canvas.nodes.filter(isGeneratedOutput);
335
+ if (outputs.length) {
336
+ findings.push(
337
+ finding(
338
+ "unused_product_reference",
339
+ "warning",
340
+ `${unusedProducts.map((node) => nodeHandle(node.id)).join(", ")} was imported as an exact product reference, but no generated output on this canvas references it. Prompt-only output is not the exact product.`,
341
+ { nodeIds: [...outputs.map((node) => cleanId(node.id)), ...unusedProducts.map((node) => cleanId(node.id))], guideId: "generated-assets" },
342
+ ),
343
+ );
344
+ }
345
+ }
327
346
  return { findings, truncatedCodes: Array.from(truncatedCodes) };
328
347
  }
329
348
 
349
+ /**
350
+ * Imported product references that nothing generated on this canvas used.
351
+ *
352
+ * `productFidelityReview` cannot see this on its own: it starts by filtering to
353
+ * outputs that already carry reference evidence, so the run that used no
354
+ * reference at all empties its input and grades itself not-applicable. The
355
+ * worst case was the one case it excused.
356
+ */
357
+ function unusedProductReferenceNodes(canvas) {
358
+ const products = canvas.nodes.filter((node) => isRecord(node.metadata) && node.metadata.productReference === true);
359
+ if (!products.length) return [];
360
+ const outputs = canvas.nodes.filter(isGeneratedOutput);
361
+ if (!outputs.length) return [];
362
+ const usedNodeIds = new Set();
363
+ const usedStorageKeys = new Set();
364
+ for (const output of outputs) {
365
+ const metadata = isRecord(output.metadata) ? output.metadata : {};
366
+ for (const value of Array.isArray(metadata.referenceNodeIds) ? metadata.referenceNodeIds : []) usedNodeIds.add(cleanId(value));
367
+ for (const value of Array.isArray(metadata.referenceHandles) ? metadata.referenceHandles : []) {
368
+ const handle = String(value || "");
369
+ if (handle.startsWith("node:")) usedNodeIds.add(cleanId(handle.slice("node:".length)));
370
+ }
371
+ for (const value of Array.isArray(metadata.references) ? metadata.references : []) usedStorageKeys.add(String(value || ""));
372
+ for (const connection of canvas.connections) {
373
+ if (connection.mode !== "reference") continue;
374
+ if (cleanId(connection.toNodeId || connection.to) !== cleanId(output.id)) continue;
375
+ usedNodeIds.add(cleanId(connection.fromNodeId || connection.from));
376
+ }
377
+ }
378
+ return products.filter((node) => {
379
+ if (usedNodeIds.has(cleanId(node.id))) return false;
380
+ const storageKey = isRecord(node.metadata) ? node.metadata.storageKey : undefined;
381
+ const content = isRecord(node.metadata) ? node.metadata.content : undefined;
382
+ return !(storageKey && usedStorageKeys.has(String(storageKey))) && !(content && usedStorageKeys.has(String(content)));
383
+ });
384
+ }
385
+
330
386
  /**
331
387
  * Detect the most specific candidate Section by center point so validation can
332
388
  * diagnose a frame that visibly escapes it. Persisted section membership uses
@@ -482,11 +538,29 @@ function reviewArea(findings, ...codes) {
482
538
  }
483
539
 
484
540
  function productFidelityReview(canvas, generatedOutputs) {
541
+ const unusedProductHandles = unusedProductReferenceNodes(canvas)
542
+ .map((node) => nodeHandle(node.id))
543
+ .sort();
485
544
  const referenceTargets = generatedOutputs.filter((node) => outputHasReferenceEvidence(node, canvas.connections));
486
545
  if (!referenceTargets.length) {
546
+ // An unused imported product is the whole reason this review exists.
547
+ // Standing down here graded the one run that ignored every reference as
548
+ // if fidelity were never in question.
549
+ if (unusedProductHandles.length && generatedOutputs.length) {
550
+ const outputHandles = generatedOutputs.map((node) => nodeHandle(node.id)).sort();
551
+ return {
552
+ status: "needs-review",
553
+ reviewedOutputCount: generatedOutputs.length,
554
+ unusedProductReferenceHandles: unusedProductHandles,
555
+ needsReviewOutputHandles: outputHandles,
556
+ evidenceMissingOutputHandles: [],
557
+ reasons: outputHandles.map((handle) => ({ nodeHandle: handle, reason: `Generated without referencing the imported product ${unusedProductHandles.join(", ")}; exact product fidelity is unverified.` })),
558
+ };
559
+ }
487
560
  return {
488
561
  status: "not-applicable",
489
562
  reviewedOutputCount: 0,
563
+ unusedProductReferenceHandles: unusedProductHandles,
490
564
  needsReviewOutputHandles: [],
491
565
  evidenceMissingOutputHandles: [],
492
566
  };
@@ -501,9 +575,10 @@ function productFidelityReview(canvas, generatedOutputs) {
501
575
  // References preserve origin and intent. They never turn a retained
502
576
  // creative output into a completion gate. Exact composition is the
503
577
  // exception: it must carry a verified finalized-image hash.
504
- status: evidenceMissing.length ? "needs-review" : "passed",
578
+ status: evidenceMissing.length || unusedProductHandles.length ? "needs-review" : "passed",
505
579
  reviewedOutputCount: referenceTargets.length,
506
- needsReviewOutputHandles: evidenceMissingOutputHandles,
580
+ unusedProductReferenceHandles: unusedProductHandles,
581
+ needsReviewOutputHandles: uniqueSorted([...evidenceMissingOutputHandles, ...(unusedProductHandles.length ? generatedOutputs.filter((node) => !outputHasReferenceEvidence(node, canvas.connections)).map((node) => nodeHandle(node.id)) : [])]),
507
582
  evidenceMissingOutputHandles,
508
583
  reasons: evidenceMissing.map((node) => ({ nodeHandle: nodeHandle(node.id), reason: "Exact composition is missing its verified output hash." })),
509
584
  };
package/src/commands.mjs CHANGED
@@ -41,8 +41,8 @@ export const GAVANA_CLI_COMMANDS = Object.freeze([
41
41
  {
42
42
  group: "auth",
43
43
  action: "login",
44
- usage: ["gavana auth login [--profile NAME] [--read-only]", "gavana auth login --base-url URL --token-stdin"],
45
- groupUsage: ["gavana auth login [--profile NAME] [--read-only] [--no-browser]", "gavana auth login --token-stdin [--profile NAME]"],
44
+ usage: ["gavana auth login [--profile NAME] [--read-only] [--timeout SECONDS]", "gavana auth login --base-url URL --token-stdin"],
45
+ groupUsage: ["gavana auth login [--profile NAME] [--read-only] [--no-browser] [--timeout SECONDS]", "gavana auth login --token-stdin [--profile NAME]"],
46
46
  },
47
47
  { group: "auth", action: "status", usage: ["gavana auth status"], groupUsage: ["gavana auth status [--profile NAME]"] },
48
48
  { group: "config", action: "list", usage: ["gavana config list | get [PROFILE] | use PROFILE"], groupUsage: ["gavana config list"] },
package/src/runner.mjs CHANGED
@@ -643,7 +643,7 @@ async function runAuthCommand(action, _args, options, env, runtime) {
643
643
  if (!token) {
644
644
  const login = runtime.oauthLogin
645
645
  ? await runtime.oauthLogin({ baseUrl, readOnly: options["read-only"] === true, profile: profile || current.profile })
646
- : await performBrowserOAuthLogin({ baseUrl, readOnly: options["read-only"] === true, noBrowser: options["no-browser"] === true, env, runtime });
646
+ : await performBrowserOAuthLogin({ baseUrl, readOnly: options["read-only"] === true, noBrowser: options["no-browser"] === true, timeoutMs: secondsOption(options.timeout, OAUTH_LOGIN_DEFAULT_TIMEOUT_MS / 1000) * 1000, env, runtime });
647
647
  token = login.accessToken;
648
648
  loginMethod = "browser";
649
649
  }
@@ -738,7 +738,10 @@ async function revokeBrowserOAuthToken(baseUrl, token, fetchImpl) {
738
738
  }
739
739
  }
740
740
 
741
- export async function performBrowserOAuthLogin({ baseUrl, readOnly = false, noBrowser = false, env = process.env, runtime = {} }) {
741
+ export const OAUTH_LOGIN_DEFAULT_TIMEOUT_MS = 180_000;
742
+
743
+ export async function performBrowserOAuthLogin({ baseUrl, readOnly = false, noBrowser = false, timeoutMs, env = process.env, runtime = {} }) {
744
+ const waitMs = timeoutMs || runtime.oauthTimeoutMs || OAUTH_LOGIN_DEFAULT_TIMEOUT_MS;
742
745
  const fetchImpl = runtime.fetchImpl || globalThis.fetch;
743
746
  if (typeof fetchImpl !== "function") throw usageError("This runtime does not provide fetch for browser login.");
744
747
  const origin = new URL(createCanvasAgentClient({ token: "configuration-validation", baseUrl, fetchImpl }).baseUrl).origin;
@@ -780,9 +783,37 @@ export async function performBrowserOAuthLogin({ baseUrl, readOnly = false, noBr
780
783
  scope: scopes,
781
784
  state,
782
785
  }).toString();
783
- (runtime.stderr || process.stderr).write(`Open this URL to connect Gavana:\n${authorize}\n`);
784
- if (!noBrowser) await (runtime.openBrowser ? runtime.openBrowser(authorize.toString()) : openBrowser(authorize.toString()));
785
- const { code } = await callback.wait(runtime.oauthTimeoutMs || 180_000);
786
+ // Whoever reads this line acts on it, and under a coding agent that
787
+ // reader is the agent. Reporting instead of ordering was not enough:
788
+ // "if no window opens, this is the URL" states a condition the reader
789
+ // cannot evaluate — it never sees the window — so it assumes the worst
790
+ // and opens the URL that is already open, and one login costs the user
791
+ // two tabs on the same authorization request. The prohibition has to be
792
+ // said outright, and only the branch where nothing opened may ask for
793
+ // the URL to be opened.
794
+ //
795
+ // The wait is announced because the caller has to survive it. An agent
796
+ // running this as a command sizes its own timeout, and every default
797
+ // worth guessing at is shorter than this one.
798
+ const stderrStream = runtime.stderr || process.stderr;
799
+ const askToOpen = () => stderrStream.write(`Open this URL to connect Gavana:\n${authorize}\n`);
800
+ const announceWait = () => stderrStream.write(`This waits up to ${Math.round(waitMs / 1000)} seconds for the sign-in to finish.\n`);
801
+ if (noBrowser) askToOpen();
802
+ else {
803
+ let opened = true;
804
+ try {
805
+ await (runtime.openBrowser ? runtime.openBrowser(authorize.toString()) : openBrowser(authorize.toString()));
806
+ } catch {
807
+ // No window came up, so a prohibition would strand the user on a
808
+ // page nobody opened. A machine with no browser is the one case
809
+ // where asking is the right instruction.
810
+ opened = false;
811
+ }
812
+ if (opened) stderrStream.write(`Your browser is opening the Gavana sign-in page. Do not open it again — this URL is already open, and is printed only so you can show it to me:\n${authorize}\n`);
813
+ else askToOpen();
814
+ }
815
+ announceWait();
816
+ const { code } = await callback.wait(waitMs);
786
817
  const tokenResponse = await fetchImpl(`${origin}/oauth/token`, {
787
818
  method: "POST",
788
819
  headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
@@ -843,7 +874,7 @@ async function createOAuthCallbackServer(expectedState) {
843
874
  Promise.race([
844
875
  result,
845
876
  new Promise((_, rejectTimeout) => {
846
- const timer = setTimeout(() => rejectTimeout(usageError("Gavana browser login timed out. Run auth login again.")), timeoutMs);
877
+ const timer = setTimeout(() => rejectTimeout(usageError("Gavana browser login timed out. The page that was open is now dead. Run auth login again, use the new URL it prints, and pass --timeout SECONDS if you need longer than this.")), timeoutMs);
847
878
  timer.unref?.();
848
879
  }),
849
880
  ]),
@@ -950,7 +981,20 @@ async function runMcpCommand(action, args, options, baseUrl, runtime) {
950
981
  if (action === "config" || !definition.command) return definition;
951
982
  const execute = runtime.execFile || execFile;
952
983
  await execute(definition.command, definition.args);
953
- return { ...definition, installed: true };
984
+ // Registering is not signing in, and `installed: true` was the last thing
985
+ // this command said. Agents read that as the end of the job and told people
986
+ // their tools were ready while the server sat unauthenticated. The steps
987
+ // that remain are known here, so they ship with the result.
988
+ return {
989
+ ...definition,
990
+ installed: true,
991
+ authenticated: false,
992
+ nextSteps: [
993
+ `Restart your client so it loads the ${definition.serverName} server.`,
994
+ `Sign this server in from inside your client — it has its own login, separate from the CLI's. In Claude Code: /mcp, choose ${definition.serverName}.`,
995
+ "Confirm with a read-only call, such as listing canvases. A status line saying connected is not proof.",
996
+ ],
997
+ };
954
998
  }
955
999
 
956
1000
  function completionScript(shell) {
package/src/version.mjs CHANGED
@@ -7,4 +7,4 @@
7
7
  //
8
8
  // scripts/gavana-mcp-tool-contract.test.mjs asserts this equals
9
9
  // packages/cli/package.json, so the two cannot drift.
10
- export const GAVANA_CLI_VERSION = "0.3.1";
10
+ export const GAVANA_CLI_VERSION = "0.3.3";