@vincentt-xr/cli 0.1.5 → 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 (66) hide show
  1. package/README.md +94 -0
  2. package/dist/cli/args.d.ts +13 -0
  3. package/dist/cli/args.js +41 -0
  4. package/dist/cli/index.js +362 -92
  5. package/dist/lib/backend.d.ts +45 -5
  6. package/dist/lib/backend.js +99 -18
  7. package/dist/lib/diagnostics.d.ts +27 -2
  8. package/dist/lib/diagnostics.js +73 -8
  9. package/dist/lib/initCommand.d.ts +14 -0
  10. package/dist/lib/initCommand.js +75 -0
  11. package/dist/lib/lifecycle.d.ts +73 -15
  12. package/dist/lib/lifecycle.js +111 -114
  13. package/dist/lib/org.d.ts +29 -0
  14. package/dist/lib/org.js +45 -0
  15. package/dist/lib/projects.d.ts +82 -0
  16. package/dist/lib/projects.js +217 -0
  17. package/dist/lib/relayInfo.d.ts +41 -3
  18. package/dist/lib/relayInfo.js +94 -0
  19. package/dist/lib/whoami.d.ts +11 -0
  20. package/dist/lib/whoami.js +38 -0
  21. package/dist/login/authBackend.d.ts +30 -0
  22. package/dist/login/authBackend.js +80 -0
  23. package/dist/login/guard.d.ts +13 -0
  24. package/dist/login/guard.js +17 -0
  25. package/dist/login/loginCommand.d.ts +39 -0
  26. package/dist/login/loginCommand.js +72 -0
  27. package/dist/login/pkce.d.ts +47 -0
  28. package/dist/login/pkce.js +106 -0
  29. package/dist/login/pkceLogin.d.ts +53 -0
  30. package/dist/login/pkceLogin.js +152 -0
  31. package/dist/mcp/server.js +47 -20
  32. package/dist/preview/api.d.ts +84 -0
  33. package/dist/preview/api.js +174 -0
  34. package/dist/preview/command.d.ts +96 -0
  35. package/dist/preview/command.js +276 -0
  36. package/dist/preview/copy.d.ts +209 -0
  37. package/dist/preview/copy.js +444 -0
  38. package/dist/preview/index.d.ts +2 -3
  39. package/dist/preview/index.js +5 -6
  40. package/dist/preview/proxy.d.ts +12 -1
  41. package/dist/preview/proxy.js +17 -7
  42. package/dist/preview/qr.d.ts +30 -0
  43. package/dist/preview/qr.js +89 -0
  44. package/dist/preview/runner.d.ts +155 -30
  45. package/dist/preview/runner.js +219 -68
  46. package/dist/preview/stream.d.ts +52 -0
  47. package/dist/preview/stream.js +171 -0
  48. package/dist/preview/viewers.d.ts +113 -0
  49. package/dist/preview/viewers.js +242 -0
  50. package/dist/relay/server.d.ts +32 -0
  51. package/dist/relay/server.js +80 -12
  52. package/dist/relay/store.d.ts +32 -3
  53. package/dist/relay/store.js +76 -22
  54. package/dist/relay/testers.d.ts +90 -0
  55. package/dist/relay/testers.js +144 -0
  56. package/dist/shared/authError.d.ts +1 -0
  57. package/dist/shared/authError.js +11 -0
  58. package/dist/shared/config.d.ts +73 -5
  59. package/dist/shared/config.js +133 -20
  60. package/package.json +2 -3
  61. package/dist/login/login.d.ts +0 -34
  62. package/dist/login/login.js +0 -148
  63. package/dist/preview/cloudflared.d.ts +0 -13
  64. package/dist/preview/cloudflared.js +0 -46
  65. package/dist/preview/tunnel.d.ts +0 -14
  66. package/dist/preview/tunnel.js +0 -28
@@ -4,108 +4,90 @@
4
4
  // MCP SDK concerns leak in here. Both surfaces render the returned data their
5
5
  // own way.
6
6
  import path from "node:path";
7
- import { BackendError, createProject, gitHeadSha, publishUpload } from "./backend.js";
8
- import { slugify } from "./slug.js";
7
+ import { getMe, gitHeadSha, publishUpload } from "./backend.js";
8
+ import { createCommand } from "./projects.js";
9
+ import { activeOrgSlug, resolveActiveOrg } from "./org.js";
9
10
  import { filterErrors, httpRelayClient, renderResult, } from "./diagnostics.js";
10
- import { clearRelayInfo, readRelayInfo, writeRelayInfo } from "./relayInfo.js";
11
- import { clearProjectBinding, loadProjectBinding, resolveConfig, writeProjectBinding, } from "../shared/config.js";
12
- import { detectPackageManager, installDependencies, needsScaffold, scaffoldFromTemplate, } from "../scaffold/index.js";
13
- import { startPreview } from "../preview/index.js";
11
+ import { clearRelayInfo, readLivePreview, readRelayInfo, writeRelayInfo, } from "./relayInfo.js";
12
+ import { loadMachineConfig, locateProjectBinding, resolveConfig, } from "../shared/config.js";
13
+ import { startPreview, } from "../preview/index.js";
14
+ /**
15
+ * The shared `create` orchestration behind both `vincentt create` and the MCP
16
+ * `project_create` tool, so the two surfaces can never diverge in behavior OR in
17
+ * what they tell the caller.
18
+ *
19
+ * It does NOT scaffold — that is `vincentt init`, which needs no account. See
20
+ * `initCommand`.
21
+ */
14
22
  export async function projectCreate(projectCwd, opts = {}) {
15
- const progress = opts.onProgress ?? (() => { });
16
- const existing = await loadProjectBinding(projectCwd);
17
- if (existing && !opts.force) {
18
- return {
19
- message: `This directory is already bound to project ${existing.projectId} (slug ${existing.slug}).\n` +
20
- `Run \`vincentt create --force\` to rebind, or \`vincentt unbind\` to detach.`,
21
- alreadyBound: true,
22
- slug: existing.slug,
23
- projectId: existing.projectId,
24
- };
25
- }
26
- if (existing)
27
- await clearProjectBinding(projectCwd);
28
- // Resolve config first so a missing PAT fails before any clone/side effect.
29
- const cfg = await resolveConfig(projectCwd);
30
- let scaffolded = false;
31
- if (opts.scaffold !== false && (await needsScaffold(projectCwd))) {
32
- progress("Scaffolding the v2-template starter…");
33
- await scaffoldFromTemplate(projectCwd);
34
- scaffolded = true;
35
- }
36
- const name = opts.name ?? path.basename(projectCwd);
37
- progress("Registering the project…");
38
- const created = await createProjectPreferringSlug(cfg, name, opts.slug);
39
- const bindingPath = await writeProjectBinding(projectCwd, {
40
- projectId: created.projectId,
41
- slug: created.slug,
42
- });
43
- const steps = [];
44
- if (scaffolded) {
45
- steps.push("Scaffolded v2-template into this directory.");
46
- const pm = await detectPackageManager(projectCwd);
47
- progress(`Installing dependencies (${pm} install — this can take a minute)…`);
48
- try {
49
- await installDependencies(projectCwd);
50
- steps.push(`Installed dependencies (${pm}).`);
51
- }
52
- catch (err) {
53
- const msg = err instanceof Error ? err.message : String(err);
54
- steps.push(`Dependencies NOT installed (${msg}) — run \`${pm} install\` before preview.`);
55
- }
56
- }
57
- const preamble = steps.length ? steps.map((s) => `✓ ${s}`).join("\n") + "\n" : "";
58
- return {
59
- message: preamble +
60
- `✓ Created "${created.name}" — slug: ${created.slug} (id ${created.projectId})\n` +
61
- ` Live URL after publish: https://${created.slug}.vincentt.app\n` +
62
- ` Binding: ${bindingPath} (gitignored)\n\n` +
63
- `Next:\n` +
64
- ` vincentt preview open a live URL on your phone\n` +
65
- ` vincentt publish go live at ${created.slug}.vincentt.app`,
66
- alreadyBound: false,
67
- slug: created.slug,
68
- projectId: created.projectId,
23
+ const lines = [];
24
+ const print = (line) => {
25
+ lines.push(line);
26
+ opts.onLine?.(line);
27
+ };
28
+ // Resolved once, lazily: a create refused by a local guard must not require a
29
+ // PAT and must not reach the network at all.
30
+ let cached;
31
+ const config = async () => {
32
+ cached ??= await resolveConfig(projectCwd, {
33
+ allowCustomApi: opts.allowCustomApi,
34
+ warn: print,
35
+ });
36
+ return cached;
69
37
  };
38
+ const exitCode = await createCommand({
39
+ print,
40
+ cwd: projectCwd,
41
+ config,
42
+ org: async () => resolveOrgForCommand(await config(), { flag: opts.org }),
43
+ }, { name: opts.name, slug: opts.slug, force: opts.force });
44
+ return { message: lines.join("\n"), exitCode };
70
45
  }
71
46
  /**
72
- * Create the backend project, preferring a slug derived from the name so the live
73
- * URL reads like the app (`my-app.vincentt.app`) instead of a random one. An
74
- * explicit `slug` is authoritative — a collision surfaces so the caller re-asks.
75
- * A derived slug is best-effort: on any 4xx we fall back to a platform-assigned
76
- * slug rather than failing create over a name we chose.
47
+ * Resolve which org a create acts in: the named one if it is an ACTIVE membership,
48
+ * else the personal org when nothing is named. A named org the caller is not an
49
+ * active member of resolves to nothing rather than silently falling back — acting
50
+ * in an org the creator did not ask for is the retargeting the binding rules forbid.
77
51
  */
78
- async function createProjectPreferringSlug(cfg, name, explicitSlug) {
79
- if (explicitSlug)
80
- return createProject(cfg, name, explicitSlug);
81
- const candidate = slugify(name);
82
- if (!candidate)
83
- return createProject(cfg, name);
84
- try {
85
- return await createProject(cfg, name, candidate);
86
- }
87
- catch (err) {
88
- if (err instanceof BackendError &&
89
- err.status !== undefined &&
90
- err.status >= 400 &&
91
- err.status < 500) {
92
- return createProject(cfg, name); // taken/invalid derived slug → let the platform pick
93
- }
94
- throw err;
95
- }
52
+ export async function resolveOrgForCommand(cfg, input) {
53
+ const machine = await loadMachineConfig();
54
+ const named = {
55
+ flag: input.flag,
56
+ env: process.env.VINCENTT_ORG,
57
+ config: machine.activeOrg,
58
+ };
59
+ const me = await getMe(cfg);
60
+ const org = resolveActiveOrg(me, named);
61
+ if (org)
62
+ return { ok: true, org };
63
+ const asked = activeOrgSlug(named);
64
+ return {
65
+ ok: false,
66
+ message: asked
67
+ ? `✗ You are not an active member of "${asked.slug}".\n\n Run \`vincentt whoami\` to see which organizations you belong to.`
68
+ : "✗ No active organization.\n\n Run `vincentt whoami` to see which organizations you belong to,\n then re-run with `--org <slug>`.",
69
+ };
96
70
  }
97
71
  export async function projectPublish(projectCwd, opts = {}) {
98
- const binding = await loadProjectBinding(projectCwd);
99
- if (!binding) {
72
+ const cfg = await resolveConfig(projectCwd, {
73
+ allowCustomApi: opts.allowCustomApi,
74
+ warn: opts.warn,
75
+ });
76
+ const located = await locateProjectBinding(projectCwd);
77
+ if (!located) {
100
78
  return {
101
- message: "No project is bound to this directory. Run `vincentt create` first.",
79
+ message: [
80
+ "✗ This folder isn't linked to a project.",
81
+ "",
82
+ " vincentt link <project-id> (copy it from the console)",
83
+ " vincentt create Register a new project for this folder",
84
+ ].join("\n"),
102
85
  notBound: true,
103
86
  };
104
87
  }
105
- const cfg = await resolveConfig(projectCwd);
106
88
  const dist = path.resolve(projectCwd, opts.distDir ?? "dist");
107
89
  const commitSha = await gitHeadSha(projectCwd);
108
- const result = await publishUpload(cfg, binding.projectId, dist, {
90
+ const result = await publishUpload(cfg, located.binding.projectId, dist, {
109
91
  note: opts.note,
110
92
  commitSha,
111
93
  });
@@ -123,10 +105,28 @@ let activeProjectCwd = null;
123
105
  export function getActivePreview() {
124
106
  return activePreview;
125
107
  }
126
- export async function previewStart(projectCwd, onLog) {
108
+ /**
109
+ * Resolve the binding and the config, then start. The binding is a HINT and
110
+ * never an authority (B12): the projectId it carries is presented to the API,
111
+ * which resolves the org itself and answers 404 for every unreachable cause.
112
+ */
113
+ export async function previewStart(projectCwd, onLog, extra = {}) {
127
114
  if (activePreview)
128
115
  return { preview: activePreview, started: false };
129
- const preview = await startPreview({ projectCwd, onLog });
116
+ const located = await locateProjectBinding(projectCwd);
117
+ if (!located) {
118
+ throw new Error("This folder isn't linked to a project.\n\n" +
119
+ " vincentt link <project-id> (copy it from the console)\n" +
120
+ " vincentt create Register a new project for this folder");
121
+ }
122
+ const config = await resolveConfig(projectCwd, { warn: onLog });
123
+ const preview = await startPreview({
124
+ ...extra,
125
+ projectCwd,
126
+ config,
127
+ projectId: located.binding.projectId,
128
+ onLog,
129
+ });
130
130
  activePreview = preview;
131
131
  activeProjectCwd = projectCwd;
132
132
  // Record the OS-assigned relay port so a separate `vincentt logs`/`feedback`
@@ -135,7 +135,6 @@ export async function previewStart(projectCwd, onLog) {
135
135
  relayPort: preview.relayPort,
136
136
  url: preview.url,
137
137
  pid: process.pid,
138
- tunnelPid: preview.tunnelPid,
139
138
  harnessPath: "/__harness",
140
139
  });
141
140
  return { preview, started: true };
@@ -164,11 +163,14 @@ function isAlive(pid) {
164
163
  /**
165
164
  * Stop a preview started by a *different* process (the common case: `vincentt
166
165
  * preview` runs in the background and the agent later wants it down). Reads the
167
- * pidfile and signals the recorded pids rather than the in-process `activePreview`.
168
- * SIGTERM to the preview process runs its graceful handler (which reaps the tunnel
169
- * + backend DNS route); if that process is already gone, its cloudflared child may
170
- * be orphaned to init, so kill the recorded tunnel pid directly. Either way, clear
171
- * the (now stale) relay info.
166
+ * pidfile and signals the recorded pid rather than the in-process
167
+ * `activePreview`.
168
+ *
169
+ * There is NO orphaned-tunnel case any more, and that is the point of the edge
170
+ * repoint: the tunnel is a socket this process owns rather than a child process
171
+ * that could be reparented to init. When the preview process dies the socket
172
+ * closes, the edge sees the tunnel go, and the session drains on its own — so
173
+ * SIGTERM plus clearing the stale relay info is the whole of the cleanup.
172
174
  */
173
175
  export async function previewStopSignal(projectCwd) {
174
176
  const info = await readRelayInfo(projectCwd);
@@ -176,36 +178,30 @@ export async function previewStopSignal(projectCwd) {
176
178
  return { stopped: false, message: "No preview is running for this directory." };
177
179
  }
178
180
  const previewAlive = info.pid !== undefined && isAlive(info.pid);
179
- const reaped = [];
180
181
  if (previewAlive) {
181
182
  try {
182
183
  process.kill(info.pid, "SIGTERM");
183
- reaped.push("preview process");
184
+ await clearRelayInfo(projectCwd);
185
+ return { stopped: true, message: "✓ Preview stopped. The link no longer works." };
184
186
  }
185
187
  catch {
186
- /* raced with its own exit — fall through to tunnel cleanup */
187
- }
188
- }
189
- // Only force-kill the tunnel directly when no live preview process will reap it.
190
- if (!previewAlive && info.tunnelPid !== undefined && isAlive(info.tunnelPid)) {
191
- try {
192
- process.kill(info.tunnelPid, "SIGKILL");
193
- reaped.push("orphaned tunnel");
194
- }
195
- catch {
196
- /* already gone */
188
+ // Raced with its own exit; the socket is gone either way.
197
189
  }
198
190
  }
199
191
  await clearRelayInfo(projectCwd);
200
- return reaped.length > 0
201
- ? { stopped: true, message: `Stopped preview (${reaped.join(", ")}).` }
202
- : { stopped: false, message: "No live preview process found; cleared stale relay info." };
192
+ return {
193
+ stopped: false,
194
+ message: "No live preview process found; cleared stale relay info.",
195
+ };
203
196
  }
204
197
  /** Resolve the relay client for a project's running preview, or throw a hint. */
205
198
  async function relayForProject(projectCwd, override) {
206
199
  if (override)
207
200
  return override;
208
- const info = await readRelayInfo(projectCwd);
201
+ // VALIDATED, for the same reason `vincentt preview` validates it: a stale
202
+ // marker would point these reads at a dead port and surface a connection
203
+ // error instead of the one sentence that tells the creator what to do.
204
+ const info = await readLivePreview(projectCwd);
209
205
  if (!info) {
210
206
  throw new Error("No running preview for this project — run `vincentt preview` first.");
211
207
  }
@@ -216,9 +212,10 @@ export async function diagQuery(projectCwd, kind, opts = {}, relay) {
216
212
  const client = await relayForProject(projectCwd, relay);
217
213
  const q = {
218
214
  kind,
219
- sessionId: opts.sessionId,
215
+ tester: opts.tester,
220
216
  since: opts.since,
221
217
  limit: opts.limit,
218
+ viewer: opts.viewer,
222
219
  };
223
220
  const result = await client.query(q);
224
221
  return kind === "log" && opts.errorsOnly ? filterErrors(result) : result;
@@ -0,0 +1,29 @@
1
+ import type { Me } from "./backend.js";
2
+ export type OrgSource = "flag" | "env" | "config";
3
+ export interface ActiveOrgInput {
4
+ /** --org <slug> */
5
+ flag?: string;
6
+ /** VINCENTT_ORG */
7
+ env?: string;
8
+ /** ~/.vincentt/config.json activeOrg */
9
+ config?: string;
10
+ }
11
+ /** flag → env → machine config. Nothing else; no inference, no folder-derived default. */
12
+ export declare function activeOrgSlug(input: ActiveOrgInput): {
13
+ slug: string;
14
+ source: OrgSource;
15
+ } | undefined;
16
+ export interface ResolvedOrg {
17
+ slug: string;
18
+ name: string;
19
+ kind: "personal" | "team";
20
+ }
21
+ /**
22
+ * Pair a resolved slug with the membership `/me` reports for it. A slug that
23
+ * names no membership resolves to NOTHING rather than to a plausible default —
24
+ * acting in an org the creator did not name is silent retargeting.
25
+ *
26
+ * With no slug configured at all, the personal org is the fallback, matching what
27
+ * `vincentt login` already prints as the active org on a fresh machine.
28
+ */
29
+ export declare function resolveActiveOrg(me: Me, input: ActiveOrgInput): ResolvedOrg | undefined;
@@ -0,0 +1,45 @@
1
+ // Which org does a command act in? Resolved by PRECEDENCE, from sources the person
2
+ // running the command controls — never from the folder's binding.
3
+ //
4
+ // `whoami` answers "who am I", so reading the binding would make it answer a
5
+ // question about the folder instead, which is precisely the mystery a creator
6
+ // running it is trying to escape.
7
+ /** flag → env → machine config. Nothing else; no inference, no folder-derived default. */
8
+ export function activeOrgSlug(input) {
9
+ if (input.flag)
10
+ return { slug: input.flag, source: "flag" };
11
+ if (input.env)
12
+ return { slug: input.env, source: "env" };
13
+ if (input.config)
14
+ return { slug: input.config, source: "config" };
15
+ return undefined;
16
+ }
17
+ /**
18
+ * Pair a resolved slug with the membership `/me` reports for it. A slug that
19
+ * names no membership resolves to NOTHING rather than to a plausible default —
20
+ * acting in an org the creator did not name is silent retargeting.
21
+ *
22
+ * With no slug configured at all, the personal org is the fallback, matching what
23
+ * `vincentt login` already prints as the active org on a fresh machine.
24
+ */
25
+ export function resolveActiveOrg(me, input) {
26
+ const memberships = (me.memberships ?? []).filter(isActive);
27
+ const named = activeOrgSlug(input);
28
+ if (named) {
29
+ const match = memberships.find((m) => m.orgSlug === named.slug);
30
+ return match ? toOrg(match) : undefined;
31
+ }
32
+ const personal = memberships.find((m) => m.orgKind === "personal");
33
+ return personal ? toOrg(personal) : undefined;
34
+ }
35
+ /**
36
+ * An invited-but-never-accepted membership carries its INTENDED role from the
37
+ * moment the invite is sent, so a bare role read would treat a pending invite as
38
+ * a real one. Only active rows are memberships as far as the CLI is concerned.
39
+ */
40
+ function isActive(m) {
41
+ return m.status === "active";
42
+ }
43
+ function toOrg(m) {
44
+ return { slug: m.orgSlug, name: m.orgName, kind: m.orgKind };
45
+ }
@@ -0,0 +1,82 @@
1
+ import { type ResolvedConfig } from "../shared/config.js";
2
+ export interface ProjectCommandDeps {
3
+ /** Print a transcript line. */
4
+ print: (line: string) => void;
5
+ /** The directory the command acts in. */
6
+ cwd: string;
7
+ /** Resolve the API host + PAT (the apiUrl gate lives here). */
8
+ config: () => Promise<ResolvedConfig>;
9
+ }
10
+ export interface CreateCommandDeps extends ProjectCommandDeps {
11
+ /**
12
+ * The org to create in, resolved LAZILY: every local guard (already linked,
13
+ * unusable name) runs before this is called, so a refused create makes no
14
+ * server request at all.
15
+ */
16
+ org: () => Promise<{
17
+ ok: true;
18
+ org: {
19
+ slug: string;
20
+ name: string;
21
+ };
22
+ } | {
23
+ ok: false;
24
+ message: string;
25
+ }>;
26
+ }
27
+ export interface CreateCommandOptions {
28
+ name?: string;
29
+ slug?: string;
30
+ /** Replace a binding this folder already has. */
31
+ force?: boolean;
32
+ }
33
+ /**
34
+ * `vincentt create` — register a project and link this folder to it.
35
+ *
36
+ * It does NOT scaffold. An unscaffolded folder gets a `!` advisory and the command
37
+ * PROCEEDS: the server accepts a create from any folder or none, and blocking here
38
+ * would put the open template behind the auth gate, which is the one thing the
39
+ * init/create split exists to prevent.
40
+ */
41
+ export declare function createCommand(deps: CreateCommandDeps, opts?: CreateCommandOptions): Promise<number>;
42
+ /**
43
+ * `vincentt link <project-id>` — one verb for four situations (console-first
44
+ * birth, a fresh clone, a folder that lost its binding, a re-link after
45
+ * `login --force`). It is a READ plus a local write: the server has no notion of
46
+ * "which folder", so linking is entirely client-side, gated only by the server's
47
+ * willingness to return the project at all.
48
+ */
49
+ export declare function linkCommand(deps: ProjectCommandDeps, projectId: string | undefined): Promise<number>;
50
+ /**
51
+ * The API's error messages are lowercase and unpunctuated by convention
52
+ * (`that handle is taken`, `this project is not available to you`), while both
53
+ * client surfaces present them as sentences. Casing is the ONLY thing either
54
+ * surface may change — the words are the server's, so a creator moving between
55
+ * the CLI and the console reads one sentence and support can match a screenshot
56
+ * to a log line without a translation table.
57
+ */
58
+ export declare function asSentence(message: string): string;
59
+ /**
60
+ * Flow L10. Line 1 is the API's message; the body names both candidate causes and
61
+ * resolves neither (that is the maximum the anti-oracle contract permits, and it
62
+ * is accurate for all four real causes). `whoami` is offered first because it is
63
+ * the one diagnosis that discloses nothing about the project.
64
+ */
65
+ export declare function printUnreachable(print: (line: string) => void, message: string): void;
66
+ /**
67
+ * `vincentt unlink` — deletes the local binding. NO server call: the binding is a
68
+ * local pointer and the project is untouched, so there is nothing to tell the
69
+ * server about.
70
+ */
71
+ export declare function unlinkCommand(deps: ProjectCommandDeps): Promise<number>;
72
+ /**
73
+ * The advisory's trigger: a package.json that depends on @vincentt-xr/sdk. It is a
74
+ * HINT only — create proceeds either way — so a false negative costs one extra
75
+ * line of output, never a refused command.
76
+ */
77
+ export declare function looksLikeVincenttApp(cwd: string): Promise<boolean>;
78
+ /** Resolve the bound project for a project-scoped verb, or explain why not. */
79
+ export declare function requireBinding(deps: ProjectCommandDeps): Promise<{
80
+ projectId: string;
81
+ slug: string;
82
+ } | undefined>;