@mediamonks/canopy-cli 0.7.32 → 0.7.34

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 (34) hide show
  1. package/dist/browse-Dycwj0V_.mjs +89 -0
  2. package/dist/{dev-CkhBnSlv.mjs → dev-CGSIY1-y.mjs} +4 -4
  3. package/dist/dev-DO6Eso4L.mjs +2 -0
  4. package/dist/{dev-worker-CaCsGBkn.mjs → dev-worker-JbLEXmVg.mjs} +1 -1
  5. package/dist/docs/agent-browsing.md +58 -0
  6. package/dist/docs/cli-browse.md +48 -0
  7. package/dist/docs/cli-create.md +1 -0
  8. package/dist/docs/cli-repo.md +87 -0
  9. package/dist/docs/getting-started.md +3 -0
  10. package/dist/docs/git.md +86 -0
  11. package/dist/docs/index.json +20 -0
  12. package/dist/{docs-CtNH5kbN.mjs → docs-Cdoh5sMW.mjs} +1 -1
  13. package/dist/{feedback-_L3vqktY.mjs → feedback-vvysW6iw.mjs} +1 -1
  14. package/dist/index.mjs +18 -15
  15. package/dist/{local-DAYCNUyz.mjs → local-qR8CBseB.mjs} +2 -2
  16. package/dist/{login-B-m-i8uu.mjs → login-D0J5s_Ex.mjs} +3 -1
  17. package/dist/{mcp-RcN17Z2v.mjs → mcp-OR_ashGC.mjs} +1 -1
  18. package/dist/{package-YGjjin4p.mjs → package-Doigafvd.mjs} +1 -1
  19. package/dist/{projects-CN2YhCZn.mjs → projects-DQAhCZm8.mjs} +13 -1
  20. package/dist/repo-1RQ1vBMv.mjs +2 -0
  21. package/dist/repo-Bb9ntNa4.mjs +423 -0
  22. package/dist/repo-credential-CNDRM-tG.mjs +118 -0
  23. package/dist/skill/canopy/SKILL.md +13 -1
  24. package/dist/{skill-CM7R-4Bq.mjs → skill-DfSAZ2cW.mjs} +1 -1
  25. package/dist/{src-BkACMkol.mjs → src-DE37PKho.mjs} +6 -2
  26. package/dist/templates/spa/AGENTS.md +13 -0
  27. package/dist/templates/spa/_gitignore +4 -0
  28. package/dist/templates/static/AGENTS.md +11 -0
  29. package/dist/templates/static/_gitignore +4 -0
  30. package/dist/templates/worker/AGENTS.md +13 -0
  31. package/dist/templates/worker/_gitignore +3 -0
  32. package/dist/{upgrade-ChcSZW8i.mjs → upgrade-DCQ3NzoL.mjs} +1 -1
  33. package/package.json +1 -1
  34. package/dist/dev-MoHHjQh1.mjs +0 -2
@@ -0,0 +1,2 @@
1
+ import { i as repoCommand } from "./repo-Bb9ntNa4.mjs";
2
+ export { repoCommand };
@@ -0,0 +1,423 @@
1
+ import { a as projectDirArgs, c as resolveProject, n as siteConfig, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
2
+ import { i as apiRequest, o as withContext } from "./api-C4rMhE7i.mjs";
3
+ import { a as unwrap, i as requireTty, r as reportErrors, t as formatDateTime } from "./ui-C_jSc1Kz.mjs";
4
+ import { a as parseCredentialInput, i as helperCommand, o as runCredentialHelper, r as fileTokenCache, t as RepoCredentialError } from "./repo-credential-CNDRM-tG.mjs";
5
+ import { defineCommand } from "citty";
6
+ import { existsSync, realpathSync } from "node:fs";
7
+ import { delimiter, join, resolve } from "node:path";
8
+ import { execFile, spawn } from "node:child_process";
9
+ import * as p from "@clack/prompts";
10
+ //#region src/git.ts
11
+ /**
12
+ * The little git the CLI needs for Canopy git repositories (OpenSpec change
13
+ * project-git-repos): find git, run it, and read or write a working copy's
14
+ * local configuration. Only `canopy repo` (and `canopy create --repo`) use
15
+ * it; nothing else in the CLI requires git to be installed.
16
+ */
17
+ /** Run git and capture its output. Never throws on a non-zero exit. */
18
+ function git(args, opts = {}) {
19
+ return new Promise((resolve, reject) => {
20
+ execFile("git", args, {
21
+ cwd: opts.cwd,
22
+ maxBuffer: 16 * 1024 * 1024
23
+ }, (err, stdout, stderr) => {
24
+ if (err && err.code === "ENOENT") {
25
+ reject(new GitNotFound());
26
+ return;
27
+ }
28
+ resolve({
29
+ code: err ? Number(err.code ?? 1) || 1 : 0,
30
+ stdout: String(stdout),
31
+ stderr: String(stderr)
32
+ });
33
+ });
34
+ });
35
+ }
36
+ /** Run git with its output on the user's terminal (clone progress and the like). */
37
+ function gitInteractive(args, opts = {}) {
38
+ return new Promise((resolve, reject) => {
39
+ const child = spawn("git", args, {
40
+ cwd: opts.cwd,
41
+ stdio: "inherit"
42
+ });
43
+ child.on("error", (err) => reject(err.code === "ENOENT" ? new GitNotFound() : err));
44
+ child.on("close", (code) => resolve(code ?? 1));
45
+ });
46
+ }
47
+ var GitNotFound = class extends Error {
48
+ constructor() {
49
+ super("git is not installed (or not on your PATH). Install it from https://git-scm.com/downloads — on a Mac, `xcode-select --install` — and try again.");
50
+ }
51
+ };
52
+ /** Throws GitNotFound when git is missing; returns the version string otherwise. */
53
+ async function requireGit() {
54
+ const { stdout } = await git(["--version"]);
55
+ return stdout.trim();
56
+ }
57
+ /** True when `dir` is inside a git working copy. */
58
+ async function isWorkingCopy(dir) {
59
+ const { code, stdout } = await git(["rev-parse", "--is-inside-work-tree"], { cwd: dir });
60
+ return code === 0 && stdout.trim() === "true";
61
+ }
62
+ /** The working copy's remotes, name → fetch URL. */
63
+ async function remotes(dir) {
64
+ const { stdout } = await git(["remote", "-v"], { cwd: dir });
65
+ const out = /* @__PURE__ */ new Map();
66
+ for (const line of stdout.split("\n")) {
67
+ const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/.exec(line.trim());
68
+ if (match) out.set(match[1], match[2]);
69
+ }
70
+ return out;
71
+ }
72
+ /** Replace every value of a key in the local configuration with `values`, in order. */
73
+ async function setLocalConfig(dir, key, values) {
74
+ const unset = await git([
75
+ "config",
76
+ "--local",
77
+ "--unset-all",
78
+ key
79
+ ], { cwd: dir });
80
+ if (unset.code !== 0 && unset.code !== 5) throw new Error(`git config --unset-all ${key} failed: ${unset.stderr.trim()}`);
81
+ for (const value of values) {
82
+ const add = await git([
83
+ "config",
84
+ "--local",
85
+ "--add",
86
+ key,
87
+ value
88
+ ], { cwd: dir });
89
+ if (add.code !== 0) throw new Error(`git config --add ${key} failed: ${add.stderr.trim()}`);
90
+ }
91
+ }
92
+ //#endregion
93
+ //#region src/repo.ts
94
+ /**
95
+ * canopy repo — the project's git repository (OpenSpec change
96
+ * project-git-repos).
97
+ *
98
+ * A project has at most one repository: a Canopy repository, which Canopy
99
+ * keeps for it, or one it links. For a Canopy repository:
100
+ *
101
+ * - `create` makes it, then sets up the current folder when it is the project;
102
+ * - `clone` clones it into a new folder;
103
+ * - `connect` sets up an existing folder;
104
+ * - `show` says which repository the project has (`--json` for agents);
105
+ * - `delete` removes it and its history (owner only).
106
+ *
107
+ * Setting up a folder points a remote at the repository and registers
108
+ * `canopy repo credential` as git's credential helper for that one
109
+ * repository. No token is ever written
110
+ * into the folder or its git configuration; git asks the helper on each fetch
111
+ * and push, and it hands over a short-lived token for the CLI sign-in.
112
+ */
113
+ /** Plain words for the API's repository errors. */
114
+ const REPO_FIXES = {
115
+ repos_not_configured: "Git repositories are not available on this site yet. Use your own GitHub repository instead.",
116
+ repo_exists: "This project already has a repository — `canopy repo show` says which.",
117
+ repo_already_linked: "This project is linked to its own GitHub repository; a project has one repository.",
118
+ repo_name_taken: "A leftover repository with this name exists on the git host. Ask a platform admin to remove it, then try again.",
119
+ owner_only: "Only the project owner can delete its repository.",
120
+ no_repo: "This project has no Canopy repository — create one with `canopy repo create`."
121
+ };
122
+ const fetchRepo = (slug) => withContext(apiRequest("GET", `/projects/${slug}/repo`), {
123
+ action: `read ${slug}'s repository`,
124
+ fixes: REPO_FIXES
125
+ });
126
+ /**
127
+ * How git should start this CLI for the credential helper: the `canopy` (or
128
+ * `canopy-dev`) on PATH when it is this very install, so upgrades keep
129
+ * working; otherwise this node binary and script, by absolute path.
130
+ */
131
+ function cliInvocation() {
132
+ const script = realpathSync(process.argv[1]);
133
+ for (const dir of (process.env["PATH"] ?? "").split(delimiter)) for (const name of ["canopy", "canopy-dev"]) {
134
+ const candidate = join(dir, name);
135
+ try {
136
+ if (existsSync(candidate) && realpathSync(candidate) === script) return [candidate];
137
+ } catch {}
138
+ }
139
+ return [process.execPath, script];
140
+ }
141
+ /** The git config lines that route this repository's credentials to the helper. */
142
+ function credentialConfig(gitUrl, profile, slug) {
143
+ const section = `credential.${gitUrl}`;
144
+ return [[`${section}.helper`, ["", helperCommand(cliInvocation(), profile, slug, gitUrl)]], [`${section}.useHttpPath`, ["true"]]];
145
+ }
146
+ function currentProfile() {
147
+ const profile = siteConfig().profileId;
148
+ if (!profile) throw new Error("Git repositories need a named site — pass --profile main, staging or gcp-pilot.");
149
+ return profile;
150
+ }
151
+ function requireCanopyRepo(slug, repo) {
152
+ if (repo.kind === "canopy") return repo;
153
+ if (repo.kind === "linked") throw new Error(`${slug} is linked to ${repo.linked_repository}; use that repository with your own git credentials.`);
154
+ throw new Error(`${slug} has no repository yet — create one with \`canopy repo create\`.`);
155
+ }
156
+ /**
157
+ * Set up `dir` as a working copy of the project's Canopy repository: a git
158
+ * repository if it isn't one, the remote (`origin`, or `canopy` when origin
159
+ * points elsewhere) and the credential helper. Returns the remote's name.
160
+ */
161
+ async function connectFolder(dir, slug, repo) {
162
+ await requireGit();
163
+ if (!await isWorkingCopy(dir)) {
164
+ const init = await git([
165
+ "init",
166
+ "-q",
167
+ "-b",
168
+ "main"
169
+ ], { cwd: dir });
170
+ if (init.code !== 0) throw new Error(`git init failed: ${init.stderr.trim()}`);
171
+ }
172
+ const existing = await remotes(dir);
173
+ let name = "origin";
174
+ const already = [...existing].find(([, url]) => url === repo.remote);
175
+ if (already) name = already[0];
176
+ else {
177
+ if (existing.has("origin")) name = "canopy";
178
+ if (existing.has(name)) throw new Error(`This folder already has a remote named "${name}" pointing elsewhere (${existing.get(name)}). Remove or rename it, then run \`canopy repo connect\` again.`);
179
+ const add = await git([
180
+ "remote",
181
+ "add",
182
+ name,
183
+ repo.remote
184
+ ], { cwd: dir });
185
+ if (add.code !== 0) throw new Error(`git remote add failed: ${add.stderr.trim()}`);
186
+ }
187
+ for (const [key, values] of credentialConfig(repo.remote, currentProfile(), slug)) await setLocalConfig(dir, key, values);
188
+ return name;
189
+ }
190
+ const showCommand = defineCommand({
191
+ meta: {
192
+ name: "show",
193
+ description: "Show the project's git repository"
194
+ },
195
+ args: {
196
+ ...projectDirArgs,
197
+ json: {
198
+ type: "boolean",
199
+ default: false,
200
+ description: "Print JSON (for scripts and agents)"
201
+ }
202
+ },
203
+ run: reportErrors("repo show", async ({ args }) => {
204
+ const { slug } = resolveProject(args);
205
+ const repo = await fetchRepo(slug);
206
+ if (args.json) {
207
+ console.log(JSON.stringify(repo, null, 2));
208
+ return;
209
+ }
210
+ if (repo.kind === "none") console.log(`${slug} has no git repository. Create one with \`canopy repo create\`.`);
211
+ else if (repo.kind === "linked") console.log(`${slug} is linked to ${repo.linked_repository}.`);
212
+ else {
213
+ console.log(`${slug} has a Canopy git repository.`);
214
+ console.log(` remote ${repo.remote}`);
215
+ console.log(` created ${repo.created_at ? formatDateTime(repo.created_at) : "—"}`);
216
+ console.log(` last push ${repo.last_push_at ? formatDateTime(repo.last_push_at) : "never"}`);
217
+ console.log(`\nClone it with \`canopy repo clone ${slug}\`.`);
218
+ }
219
+ })
220
+ });
221
+ const createCommand = defineCommand({
222
+ meta: {
223
+ name: "create",
224
+ description: "Create a Canopy git repository for the project"
225
+ },
226
+ args: { ...projectDirArgs },
227
+ run: reportErrors("repo create", async ({ args }) => {
228
+ const { dir, slug } = resolveProject(args);
229
+ const repo = await withContext(apiRequest("POST", `/projects/${slug}/repo`), {
230
+ action: `create a git repository for ${slug}`,
231
+ fixes: REPO_FIXES
232
+ });
233
+ p.log.success(`Created a git repository for ${slug}.`);
234
+ if (tryReadCanopyJson(dir)?.slug === slug) {
235
+ const remote = await connectFolder(dir, slug, repo);
236
+ p.log.info(`This folder pushes to it as "${remote}". Commit and push as usual:`);
237
+ console.log(` git add -A && git commit -m "Initial commit" && git push -u ${remote} main`);
238
+ } else p.log.info(`Clone it with \`canopy repo clone ${slug}\`.`);
239
+ })
240
+ });
241
+ const connectCommand = defineCommand({
242
+ meta: {
243
+ name: "connect",
244
+ description: "Set up a folder to push to the project's Canopy git repository"
245
+ },
246
+ args: { ...projectDirArgs },
247
+ run: reportErrors("repo connect", async ({ args }) => {
248
+ const { dir, slug } = resolveProject(args);
249
+ const remote = await connectFolder(dir, slug, requireCanopyRepo(slug, await fetchRepo(slug)));
250
+ p.log.success(`This folder now pushes to ${slug}'s repository as "${remote}".`);
251
+ })
252
+ });
253
+ const cloneCommand = defineCommand({
254
+ meta: {
255
+ name: "clone",
256
+ description: "Clone a project's Canopy git repository"
257
+ },
258
+ args: {
259
+ slug: {
260
+ type: "positional",
261
+ description: "Project slug",
262
+ required: true
263
+ },
264
+ target: {
265
+ type: "positional",
266
+ description: "Folder to clone into (defaults to the slug)",
267
+ required: false
268
+ }
269
+ },
270
+ run: reportErrors("repo clone", async ({ args }) => {
271
+ const slug = args.slug;
272
+ const repo = requireCanopyRepo(slug, await fetchRepo(slug));
273
+ await requireGit();
274
+ const target = resolve(args.target ?? slug);
275
+ const code = await gitInteractive([
276
+ "clone",
277
+ ...credentialConfig(repo.remote, currentProfile(), slug).flatMap(([key, values]) => values.flatMap((value) => ["-c", `${key}=${value}`])),
278
+ repo.remote,
279
+ target
280
+ ]);
281
+ if (code !== 0) throw new Error(`git clone failed (exit ${code}).`);
282
+ p.log.success(`Cloned ${slug} into ${target}.`);
283
+ })
284
+ });
285
+ const deleteCommand = defineCommand({
286
+ meta: {
287
+ name: "delete",
288
+ description: "Delete the project's Canopy git repository and its history"
289
+ },
290
+ args: {
291
+ ...projectDirArgs,
292
+ yes: {
293
+ type: "boolean",
294
+ default: false,
295
+ description: "Skip the type-the-slug confirmation"
296
+ }
297
+ },
298
+ run: reportErrors("repo delete", async ({ args }) => {
299
+ const { slug } = resolveProject(args);
300
+ if (!args.yes) {
301
+ requireTty("pass --yes to delete without confirmation.");
302
+ if (unwrap(await p.text({ message: `Delete ${slug}'s git repository and all its history? Type the slug to confirm.` })).trim() !== slug) {
303
+ p.cancel("Aborted.");
304
+ return;
305
+ }
306
+ }
307
+ await withContext(apiRequest("DELETE", `/projects/${slug}/repo`), {
308
+ action: `delete ${slug}'s git repository`,
309
+ fixes: REPO_FIXES
310
+ });
311
+ p.log.success(`Deleted ${slug}'s git repository.`);
312
+ })
313
+ });
314
+ /** Exchange this site's CLI sign-in for a repository token (never a deploy token). */
315
+ async function mintRepoToken(profile, slug) {
316
+ const config = siteConfig();
317
+ const login = `run \`canopy login --profile ${profile}\``;
318
+ if (!config.token) throw new RepoCredentialError(`not signed in to ${profile} — ${login}.`);
319
+ const path = `/projects/${encodeURIComponent(slug)}/repo/token`;
320
+ const res = await fetch(`${config.apiUrl}/control/v1${path}`, {
321
+ method: "POST",
322
+ headers: { authorization: `Bearer ${config.token}` }
323
+ }).catch(() => null);
324
+ if (!res) throw new RepoCredentialError(`could not reach Canopy (${config.apiUrl}).`);
325
+ if (res.status === 201) return await res.json();
326
+ const body = await res.json().catch(() => ({}));
327
+ if (res.status === 401) throw new RepoCredentialError(`your ${profile} sign-in has expired — ${login}.`);
328
+ if (res.status === 404) throw new RepoCredentialError(`you don't have access to ${slug}'s repository (or it no longer exists).`);
329
+ throw new RepoCredentialError(body.detail ?? body.error ?? `Canopy answered HTTP ${res.status}.`);
330
+ }
331
+ const credentialCommand = defineCommand({
332
+ meta: {
333
+ name: "credential",
334
+ description: "git credential helper (set up by clone/connect)",
335
+ hidden: true
336
+ },
337
+ args: {
338
+ action: {
339
+ type: "positional",
340
+ description: "get | store | erase",
341
+ required: true
342
+ },
343
+ project: {
344
+ type: "string",
345
+ description: "Project slug",
346
+ required: true
347
+ },
348
+ url: {
349
+ type: "string",
350
+ description: "Repository URL git talks to",
351
+ required: true
352
+ }
353
+ },
354
+ run: async ({ args }) => {
355
+ const profile = siteConfig().profileId;
356
+ const input = parseCredentialInput(await readStdin());
357
+ const out = profile ? await runCredentialHelper(args.action, input, {
358
+ cacheKey: `${profile}/${args.project}`,
359
+ repoUrl: args.url,
360
+ cache: fileTokenCache(),
361
+ mint: () => mintRepoToken(profile, args.project)
362
+ }) : {
363
+ stdout: "quit=1\n",
364
+ stderr: "canopy: this repository's site is unknown — run `canopy repo connect` again.\n"
365
+ };
366
+ process.stderr.write(out.stderr);
367
+ process.stdout.write(out.stdout);
368
+ }
369
+ });
370
+ async function readStdin() {
371
+ const chunks = [];
372
+ for await (const chunk of process.stdin) chunks.push(chunk);
373
+ return Buffer.concat(chunks).toString("utf8");
374
+ }
375
+ const repoCommand = defineCommand({
376
+ meta: {
377
+ name: "repo",
378
+ description: "The project's git repository (clone, connect, create)"
379
+ },
380
+ subCommands: {
381
+ show: showCommand,
382
+ clone: cloneCommand,
383
+ connect: connectCommand,
384
+ create: createCommand,
385
+ delete: deleteCommand,
386
+ credential: credentialCommand
387
+ }
388
+ });
389
+ /** Whether this site offers Canopy git repositories (the `repos` capability). */
390
+ async function reposAvailable() {
391
+ const platform = await apiRequest("GET", "/platform").catch(() => null);
392
+ return Boolean(platform?.capabilities?.repos);
393
+ }
394
+ /**
395
+ * The repository step of `canopy create` (design D-H). `--repo` creates one,
396
+ * `--no-repo` doesn't; otherwise an interactive run is asked (default no) when
397
+ * the site offers repositories and the folder doesn't already push to GitHub.
398
+ * A repository that can't be created never fails the project creation.
399
+ * Returns the remote name when the folder was set up.
400
+ */
401
+ async function offerRepoOnCreate(slug, dir, flag) {
402
+ if (flag === false) return null;
403
+ if (flag === void 0) {
404
+ if (!process.stdin.isTTY || !await reposAvailable()) return null;
405
+ const origin = await isWorkingCopy(dir).catch(() => false) ? (await remotes(dir)).get("origin") : void 0;
406
+ if (origin && /github\.com[:/]/.test(origin)) return null;
407
+ if (!unwrap(await p.confirm({
408
+ message: "Create a git repository for this project on Canopy?",
409
+ initialValue: false
410
+ }))) return null;
411
+ }
412
+ try {
413
+ return await connectFolder(dir, slug, await withContext(apiRequest("POST", `/projects/${slug}/repo`), {
414
+ action: `create a git repository for ${slug}`,
415
+ fixes: REPO_FIXES
416
+ }));
417
+ } catch (err) {
418
+ p.log.warn(`The project was created, but its git repository wasn't: ${err instanceof Error ? err.message : String(err)}\nTry again later with \`canopy repo create\`.`);
419
+ return null;
420
+ }
421
+ }
422
+ //#endregion
423
+ export { repoCommand as i, connectFolder as n, offerRepoOnCreate as r, cliInvocation as t };
@@ -0,0 +1,118 @@
1
+ import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ /** Why no token could be had — shown to the user, then git is told to quit. */
5
+ var RepoCredentialError = class extends Error {};
6
+ const REPO_TOKEN_CACHE_PATH = join(homedir(), ".canopy", "repo-tokens.json");
7
+ /** The on-disk cache: one JSON object, owner-only. */
8
+ function fileTokenCache(path = REPO_TOKEN_CACHE_PATH) {
9
+ const read = () => {
10
+ try {
11
+ return JSON.parse(readFileSync(path, "utf8"));
12
+ } catch {
13
+ return {};
14
+ }
15
+ };
16
+ const write = (all) => {
17
+ mkdirSync(dirname(path), {
18
+ recursive: true,
19
+ mode: 448
20
+ });
21
+ writeFileSync(path, `${JSON.stringify(all, null, 2)}\n`, { mode: 384 });
22
+ chmodSync(path, 384);
23
+ };
24
+ return {
25
+ get: (key) => read()[key],
26
+ set: (key, value) => write({
27
+ ...read(),
28
+ [key]: value
29
+ }),
30
+ delete: (key) => {
31
+ const all = read();
32
+ if (!(key in all)) return;
33
+ delete all[key];
34
+ write(all);
35
+ }
36
+ };
37
+ }
38
+ /** `canopy logout` forgets every cached repository token. */
39
+ function clearRepoTokenCache(path = REPO_TOKEN_CACHE_PATH) {
40
+ rmSync(path, { force: true });
41
+ }
42
+ /** git's credential protocol: `key=value` lines, terminated by a blank line or EOF. */
43
+ function parseCredentialInput(text) {
44
+ const out = /* @__PURE__ */ new Map();
45
+ for (const line of text.split("\n")) {
46
+ if (line === "") break;
47
+ const eq = line.indexOf("=");
48
+ if (eq > 0) out.set(line.slice(0, eq), line.slice(eq + 1));
49
+ }
50
+ return out;
51
+ }
52
+ const normalize = (url) => url.replace(/\/+$/, "").replace(/\.git$/, "");
53
+ /** Whether git is asking about exactly the repository this helper was registered for. */
54
+ function requestMatches(input, repoUrl) {
55
+ const protocol = input.get("protocol");
56
+ const host = input.get("host");
57
+ const path = input.get("path");
58
+ if (protocol !== "https" || !host || !path) return false;
59
+ return normalize(`${protocol}://${host}/${path}`) === normalize(repoUrl);
60
+ }
61
+ /** Answer one git credential request. */
62
+ async function runCredentialHelper(action, input, opts) {
63
+ const now = opts.now ?? Date.now;
64
+ if (!requestMatches(input, opts.repoUrl)) return {
65
+ stdout: "",
66
+ stderr: ""
67
+ };
68
+ if (action === "erase") {
69
+ opts.cache.delete(opts.cacheKey);
70
+ return {
71
+ stdout: "",
72
+ stderr: ""
73
+ };
74
+ }
75
+ if (action !== "get") return {
76
+ stdout: "",
77
+ stderr: ""
78
+ };
79
+ let token = opts.cache.get(opts.cacheKey);
80
+ if (!token || Date.parse(token.expires_at) - now() <= 3e5) {
81
+ try {
82
+ token = await opts.mint();
83
+ } catch (err) {
84
+ opts.cache.delete(opts.cacheKey);
85
+ return {
86
+ stdout: "quit=1\n",
87
+ stderr: `canopy: ${err instanceof RepoCredentialError ? err.message : String(err)}\n`
88
+ };
89
+ }
90
+ opts.cache.set(opts.cacheKey, token);
91
+ }
92
+ const expiry = Math.floor(Date.parse(token.expires_at) / 1e3);
93
+ return {
94
+ stdout: `username=x-access-token\npassword=${token.token}\npassword_expiry_utc=${expiry}\n`,
95
+ stderr: ""
96
+ };
97
+ }
98
+ /**
99
+ * The shell command git runs for the helper, as written into
100
+ * `credential.<url>.helper`. Absolute paths, so GUI git clients without the
101
+ * user's PATH still find it.
102
+ */
103
+ function helperCommand(cli, profile, slug, repoUrl) {
104
+ const quote = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
105
+ return `!${[
106
+ ...cli.map(quote),
107
+ "repo",
108
+ "credential",
109
+ "--profile",
110
+ quote(profile),
111
+ "--project",
112
+ quote(slug),
113
+ "--url",
114
+ quote(repoUrl)
115
+ ].join(" ")}`;
116
+ }
117
+ //#endregion
118
+ export { parseCredentialInput as a, helperCommand as i, clearRepoTokenCache as n, runCredentialHelper as o, fileTokenCache as r, RepoCredentialError as t };
@@ -97,6 +97,12 @@ and never go stale.
97
97
  (own database cloned from live; `canopy staging status | sync | destroy`)
98
98
  - Roll back: `canopy rollback`
99
99
  - Open the live site: `canopy open`
100
+ - **Look at a deployed project in your own browser** (Playwright, a browser
101
+ tool, curl): `canopy browse <slug> --json` prints a one-time sign-in link
102
+ (`--staging`, `--path /page`). Open it within a minute; that browser is
103
+ then signed in to that one project, as the user, for an hour. On a sign-in
104
+ page later, run it again. Never ask the user for their session cookie
105
+ (`canopy docs agent-browsing`).
100
106
  - Manage data: `canopy db pull | snapshot | snapshots | restore | migrate`
101
107
  - Use AI (`canopy.ai`): run `canopy ai models` before naming a model in code.
102
108
  Prefer the aliases `auto/low` (default) / `auto/mid` / `auto/high` over
@@ -104,6 +110,11 @@ and never go stale.
104
110
  - Work bug reports & feedback: `canopy feedback list | show | comment | status | report`
105
111
  — see [Working feedback reports](#working-feedback-reports-issues) below
106
112
  - CI credentials: `canopy token create | list | revoke`
113
+ - Git: `canopy repo show --json` (is there a repository, and its remote) ·
114
+ `canopy repo create` · `canopy repo clone <slug>` · `canopy repo connect`
115
+ (set up an existing folder). Then plain `git`: the CLI gives git
116
+ short-lived credentials itself, so never ask for, paste or store a git
117
+ token. Pushing doesn't deploy — `canopy deploy` does.
107
118
 
108
119
  ## Working feedback reports (issues)
109
120
 
@@ -183,7 +194,8 @@ Non-interactive: `canopy create "My App" --template spa`. **When unsure between
183
194
 
184
195
  ## Safety rules
185
196
 
186
- - **Destructive commands** — `canopy db restore` and `canopy delete` wipe data.
197
+ - **Destructive commands** — `canopy db restore`, `canopy delete` and
198
+ `canopy repo delete` (the project's git history) wipe data.
187
199
  Confirm intent with the user first, and take a `canopy db snapshot` before
188
200
  restores.
189
201
  - **Deploys are public.** `canopy deploy` makes a version live. Confirm before
@@ -1,4 +1,4 @@
1
- import { t as version } from "./package-YGjjin4p.mjs";
1
+ import { t as version } from "./package-Doigafvd.mjs";
2
2
  import { r as reportErrors } from "./ui-C_jSc1Kz.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { cpSync, existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
@@ -857,7 +857,11 @@ const AGENT_CREDENTIAL_HEADER = "x-canopy-agent-credential";
857
857
  */
858
858
  const CALLER_HEADER = "x-canopy-caller";
859
859
  /** Cookies that authenticate to Canopy itself — the monk session and the client-page session. */
860
- const CANOPY_COOKIE_NAMES = /* @__PURE__ */ new Set(["canopy_session", "canopy_client"]);
860
+ const CANOPY_COOKIE_NAMES = /* @__PURE__ */ new Set([
861
+ "canopy_session",
862
+ "canopy_client",
863
+ "canopy_agent"
864
+ ]);
861
865
  /**
862
866
  * Strip Canopy's own credentials from a request before untrusted project code
863
867
  * sees it (R-01).
@@ -870,7 +874,7 @@ const CANOPY_COOKIE_NAMES = /* @__PURE__ */ new Set(["canopy_session", "canopy_c
870
874
  *
871
875
  * Redaction is surgical on purpose: an app's own cookies and its own
872
876
  * Authorization scheme survive, because those belong to the app. Only
873
- * `canopy_session` / `canopy_client` and a `cnpy_` bearer are removed.
877
+ * `canopy_session` / `canopy_client` / `canopy_agent` and a `cnpy_` bearer are removed.
874
878
  *
875
879
  * `env.CANOPY.fetch` still authenticates as the visitor — the host passes it
876
880
  * the unredacted headers — so nothing about the platform-facing behaviour
@@ -35,6 +35,19 @@ the [Canopy dashboard](https://dashboard.thanarie.dev).
35
35
  `canopy promote` ships the exact tested version. Never promote with failing
36
36
  checks.
37
37
 
38
+ ## Git
39
+
40
+ Code can live in a git repository Canopy keeps for the project (no GitHub
41
+ account needed). `canopy repo show --json` says whether there is one and gives
42
+ its remote; `canopy repo create` makes one, `canopy repo clone <slug>` or
43
+ `canopy repo connect` sets a folder up. After that it is plain `git` — the CLI
44
+ hands git short-lived credentials by itself:
45
+
46
+ - Never ask for, paste or store a git token or password.
47
+ - Pushing doesn't deploy — `canopy deploy` still does.
48
+ - The `.github/workflows` file here only runs on GitHub; on a Canopy
49
+ repository it is kept but never runs.
50
+
38
51
  ## Boundaries
39
52
 
40
53
  - Do not add auth — the platform authenticates every request before it
@@ -2,3 +2,7 @@ node_modules/
2
2
  dist/
3
3
  .canopy/
4
4
  .DS_Store
5
+ # Local secrets never go into git (secrets belong in `canopy secrets`).
6
+ .dev.vars
7
+ .env*
8
+ !.env.example
@@ -34,6 +34,17 @@ the [Canopy dashboard](https://dashboard.thanarie.dev).
34
34
  `canopy promote` ships the exact tested version. Never promote with failing
35
35
  checks.
36
36
 
37
+ ## Git
38
+
39
+ Code can live in a git repository Canopy keeps for the project (no GitHub
40
+ account needed). `canopy repo show --json` says whether there is one and gives
41
+ its remote; `canopy repo create` makes one, `canopy repo clone <slug>` or
42
+ `canopy repo connect` sets a folder up. After that it is plain `git` — the CLI
43
+ hands git short-lived credentials by itself:
44
+
45
+ - Never ask for, paste or store a git token or password.
46
+ - Pushing doesn't deploy — `canopy deploy` still does.
47
+
37
48
  ## Boundaries
38
49
 
39
50
  - Do not add auth — the platform authenticates every request before it
@@ -1,2 +1,6 @@
1
1
  .canopy/
2
2
  .DS_Store
3
+ # Local secrets never go into git (secrets belong in `canopy secrets`).
4
+ .dev.vars
5
+ .env*
6
+ !.env.example
@@ -51,6 +51,19 @@ the [Canopy dashboard](https://dashboard.thanarie.dev).
51
51
  `canopy promote` ships the exact tested version. Never promote with failing
52
52
  checks.
53
53
 
54
+ ## Git
55
+
56
+ Code can live in a git repository Canopy keeps for the project (no GitHub
57
+ account needed). `canopy repo show --json` says whether there is one and gives
58
+ its remote; `canopy repo create` makes one, `canopy repo clone <slug>` or
59
+ `canopy repo connect` sets a folder up. After that it is plain `git` — the CLI
60
+ hands git short-lived credentials by itself:
61
+
62
+ - Never ask for, paste or store a git token or password.
63
+ - Pushing doesn't deploy — `canopy deploy` still does.
64
+ - The `.github/workflows` file here only runs on GitHub; on a Canopy
65
+ repository it is kept but never runs.
66
+
54
67
  ## Boundaries
55
68
 
56
69
  - Do not add auth — the platform authenticates every request before it