@mediamonks/canopy-cli 0.7.44 → 0.7.46

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 (55) hide show
  1. package/dist/{ai-FBHTSCLZ.mjs → ai-CWPQiUft.mjs} +2 -2
  2. package/dist/{api-Bl6SG8Oq.mjs → api-DmJrIsE7.mjs} +134 -19
  3. package/dist/{browse-B8-0dweq.mjs → browse-BPLWhoFI.mjs} +2 -2
  4. package/dist/{db-BJJGydgL.mjs → db-DpCMc0uz.mjs} +4 -4
  5. package/dist/{deploy-ExrIeX_m.mjs → deploy-BnRQusjJ.mjs} +51 -5
  6. package/dist/{dev-Cq98yblV.mjs → dev-BmKeIdMi.mjs} +7 -7
  7. package/dist/dev-BvVlq9np.mjs +2 -0
  8. package/dist/docs/ci.md +112 -50
  9. package/dist/docs/cli-create.md +2 -0
  10. package/dist/docs/cli-deploy.md +1 -1
  11. package/dist/docs/cli-members.md +18 -11
  12. package/dist/docs/cli-repo.md +34 -2
  13. package/dist/docs/cli-token.md +8 -6
  14. package/dist/docs/cli.md +6 -2
  15. package/dist/docs/deploy.md +3 -2
  16. package/dist/docs/index.json +5 -5
  17. package/dist/docs/platform-deploys-and-rollbacks.md +4 -4
  18. package/dist/docs/platform-members-and-access.md +28 -9
  19. package/dist/docs/roles.md +1 -1
  20. package/dist/{docs-KmN6jD1S.mjs → docs-CtMrMpt1.mjs} +4 -4
  21. package/dist/{egress-C2amvqmE.mjs → egress-BM4EpLBh.mjs} +3 -3
  22. package/dist/{feedback-B5-95neo.mjs → feedback-BXhbxyXI.mjs} +5 -5
  23. package/dist/index.mjs +35 -35
  24. package/dist/{local-CIkL7tXP.mjs → local-C9Bi9f3y.mjs} +2 -2
  25. package/dist/{login-CREcZfLH.mjs → login-2MW9sGJO.mjs} +3 -3
  26. package/dist/{logs-BnrNZvLc.mjs → logs-dZMPTJLu.mjs} +3 -3
  27. package/dist/{mcp-DZdMNTWr.mjs → mcp-CGmWkGN8.mjs} +3 -3
  28. package/dist/{migrations-D8NqjC5I.mjs → migrations-BReMlGWP.mjs} +2 -2
  29. package/dist/{open-Bpz4C2Sr.mjs → open-DcQSdfx5.mjs} +2 -2
  30. package/dist/{package-Bvly8-rB.mjs → package-BdNHq8QV.mjs} +1 -1
  31. package/dist/{project-template-Cdr1uwwE.mjs → project-template-BPW_Dgxj.mjs} +3 -3
  32. package/dist/project-template-Dw11151p.mjs +2 -0
  33. package/dist/{projects-BRG2HnBs.mjs → projects-CnU3194_.mjs} +26 -8
  34. package/dist/{rename-BGKa25Rq.mjs → rename-BWvERJgA.mjs} +3 -3
  35. package/dist/{repo-CwT2RDoW.mjs → repo-6a7gRQaf.mjs} +171 -8
  36. package/dist/repo-C7AXDtFZ.mjs +2 -0
  37. package/dist/{roles-BDZaN3vi.mjs → roles-CrYf_8wk.mjs} +1 -1
  38. package/dist/{roles-UM0Iiws6.mjs → roles-S2D9Id0W.mjs} +71 -13
  39. package/dist/{schedule-BcqBspn3.mjs → schedule-DvHSSzfR.mjs} +3 -3
  40. package/dist/{secrets-BjbIi7Gp.mjs → secrets-BIn2RPoe.mjs} +3 -3
  41. package/dist/{site-0i7wAJqg.mjs → site-BL5Ik-yj.mjs} +50 -1
  42. package/dist/{skill-DRLniv-I.mjs → skill-vzcCD1b4.mjs} +2 -2
  43. package/dist/{staging-_2Js0Fkc.mjs → staging-Dn8kH9cK.mjs} +3 -3
  44. package/dist/templates/spa/.github/workflows/canopy-deploy.yml +18 -12
  45. package/dist/templates/spa/README.md +1 -1
  46. package/dist/templates/worker/.github/workflows/canopy-deploy.yml +18 -12
  47. package/dist/templates/worker/README.md +1 -1
  48. package/dist/{token-7vSNuz-X.mjs → token-DMCj88A-.mjs} +6 -6
  49. package/dist/{ui-CLnICOne.mjs → ui-26ETk1n8.mjs} +1 -1
  50. package/dist/{upgrade-D_yrD7zp.mjs → upgrade-8_pFNTo5.mjs} +2 -2
  51. package/dist/{usage-DGGmTwjJ.mjs → usage-Bpz5B1N1.mjs} +3 -3
  52. package/package.json +1 -1
  53. package/dist/dev-CUJKgaqJ.mjs +0 -2
  54. package/dist/project-template-715H_lnu.mjs +0 -2
  55. package/dist/repo-DmFs6H9m.mjs +0 -2
@@ -1,8 +1,8 @@
1
- import { c as projectUrl, f as tryReadCanopyJson, r as siteConfig } from "./site-0i7wAJqg.mjs";
2
- import { a as apiRequest, f as withContext } from "./api-Bl6SG8Oq.mjs";
3
- import { a as unwrap, i as requireTty, r as reportErrors } from "./ui-CLnICOne.mjs";
4
- import { i as nextSteps, n as describeCopy, r as fetchTemplate, t as TEMPLATE_FIXES } from "./project-template-Cdr1uwwE.mjs";
5
- import { i as offerRepoOnCreate, n as cloneProjectRepo } from "./repo-CwT2RDoW.mjs";
1
+ import { m as tryReadCanopyJson, r as siteConfig, u as projectUrl } from "./site-BL5Ik-yj.mjs";
2
+ import { a as apiRequest, m as withContext } from "./api-DmJrIsE7.mjs";
3
+ import { a as unwrap, i as requireTty, r as reportErrors } from "./ui-26ETk1n8.mjs";
4
+ import { i as nextSteps, n as describeCopy, r as fetchTemplate, t as TEMPLATE_FIXES } from "./project-template-BPW_Dgxj.mjs";
5
+ import { a as offerRepoOnCreate, i as githubOrigin, n as cloneProjectRepo } from "./repo-6a7gRQaf.mjs";
6
6
  import { defineCommand } from "citty";
7
7
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
8
8
  import { join, relative, resolve, sep } from "node:path";
@@ -84,6 +84,17 @@ function slugify(input) {
84
84
  function shouldScaffoldCreate(requested, cwd = process.cwd()) {
85
85
  return requested ?? !existsSync(join(cwd, "canopy.json"));
86
86
  }
87
+ /**
88
+ * The "deploy from GitHub" next step (OpenSpec platform-oauth-oidc). Never a
89
+ * link: linking turns CI deploys on, which is the owner's call. With a GitHub
90
+ * `origin` the exact command; without one the shape of it. A folder that
91
+ * pushes to a Canopy repository needs no link, so no step.
92
+ */
93
+ async function ciLinkStep(dir, canopyRemote) {
94
+ if (canopyRemote) return [];
95
+ const origin = await githubOrigin(dir);
96
+ return origin ? [`canopy repo link ${origin.host}/${origin.repository} (deploy on every push to main)`] : ["canopy repo link <owner/name> (once it's on GitHub: deploy on every push to main)"];
97
+ }
87
98
  const createCommand = defineCommand({
88
99
  meta: {
89
100
  name: "create",
@@ -186,13 +197,15 @@ const createCommand = defineCommand({
186
197
  `cd ${slug}`,
187
198
  ...template.needsInstall ? ["npm install"] : [],
188
199
  ...remote ? [`git add -A && git commit -m "Initial commit" && git push -u ${remote} main`] : [],
189
- "canopy dev (or deploy right away: canopy deploy)"
200
+ "canopy dev (or deploy right away: canopy deploy)",
201
+ ...await ciLinkStep(targetDir, remote)
190
202
  ];
191
203
  p.note(steps.join("\n"), `Scaffolded ./${slug}/ (${template.id}) — next`);
192
204
  p.outro(`Live URL (after first deploy): ${projectUrl(project.slug)}`);
193
205
  } else {
194
206
  const remote = await offerRepoOnCreate(slug, process.cwd(), args.repo);
195
207
  if (remote) p.log.info(`This folder pushes to the project's git repository as "${remote}".`);
208
+ for (const step of await ciLinkStep(process.cwd(), remote)) p.log.info(`Next: ${step}`);
196
209
  p.outro(existsSync(join(process.cwd(), "canopy.json")) ? "Registered without scaffolding — the existing canopy.json was left unchanged." : "Registered without scaffolding — add a canopy.json to deploy an existing app.");
197
210
  }
198
211
  })
@@ -319,7 +332,8 @@ const initCommand = defineCommand({
319
332
  const steps = [
320
333
  ...rel === "." ? [] : [`cd ${rel}`],
321
334
  ...template.needsInstall ? ["npm install"] : [],
322
- "canopy dev (or deploy right away: canopy deploy)"
335
+ "canopy dev (or deploy right away: canopy deploy)",
336
+ ...await ciLinkStep(targetDir, null)
323
337
  ];
324
338
  p.note(steps.join("\n"), `Scaffolded ${rel}/ (${template.id}) for ${slug} — next`);
325
339
  p.outro(`Live URL (after first deploy): ${projectUrl(slug)}`);
@@ -333,7 +347,11 @@ const initCommand = defineCommand({
333
347
  return;
334
348
  }
335
349
  writeCanopyJson(targetDir, { slug });
336
- p.note("Add \"dist\"/\"build\" to canopy.json if this app has a build step, then:\ncanopy deploy", `Linked ${rel} to ${slug}`);
350
+ p.note([
351
+ "Add \"dist\"/\"build\" to canopy.json if this app has a build step, then:",
352
+ "canopy deploy",
353
+ ...await ciLinkStep(targetDir, null)
354
+ ].join("\n"), `Linked ${rel} to ${slug}`);
337
355
  p.outro(`Live URL (after first deploy): ${projectUrl(slug)}`);
338
356
  })
339
357
  });
@@ -1,6 +1,6 @@
1
- import { c as projectUrl, l as resolveProject, o as projectDirArgs, u as rewriteCanopyJsonSlug } from "./site-0i7wAJqg.mjs";
2
- import { a as apiRequest, f as withContext } from "./api-Bl6SG8Oq.mjs";
3
- import { a as unwrap, i as requireTty, r as reportErrors } from "./ui-CLnICOne.mjs";
1
+ import { c as projectDirArgs, d as resolveProject, f as rewriteCanopyJsonSlug, u as projectUrl } from "./site-BL5Ik-yj.mjs";
2
+ import { a as apiRequest, m as withContext } from "./api-DmJrIsE7.mjs";
3
+ import { a as unwrap, i as requireTty, r as reportErrors } from "./ui-26ETk1n8.mjs";
4
4
  import { defineCommand } from "citty";
5
5
  import * as p from "@clack/prompts";
6
6
  //#region src/rename.ts
@@ -1,6 +1,6 @@
1
- import { f as tryReadCanopyJson, l as resolveProject, o as projectDirArgs, r as siteConfig } from "./site-0i7wAJqg.mjs";
2
- import { a as apiRequest, d as noticeRenamesOnly, f as withContext, n as agentHeaders, u as followRename } from "./api-Bl6SG8Oq.mjs";
3
- import { a as unwrap, i as requireTty, r as reportErrors, t as formatDateTime } from "./ui-CLnICOne.mjs";
1
+ import { a as parseGithubRemote, c as projectDirArgs, d as resolveProject, l as projectFlagArgs, m as tryReadCanopyJson, o as parseRepositoryArg, r as siteConfig } from "./site-BL5Ik-yj.mjs";
2
+ import { a as apiRequest, f as noticeRenamesOnly, m as withContext, n as agentHeaders, u as followRename } from "./api-DmJrIsE7.mjs";
3
+ import { a as unwrap, i as requireTty, r as reportErrors, t as formatDateTime } from "./ui-26ETk1n8.mjs";
4
4
  import { a as parseCredentialInput, i as helperCommand, o as runCredentialHelper, r as fileTokenCache, t as RepoCredentialError } from "./repo-credential-CNDRM-tG.mjs";
5
5
  import { defineCommand } from "citty";
6
6
  import { existsSync, realpathSync } from "node:fs";
@@ -104,6 +104,13 @@ async function setLocalConfig(dir, key, values) {
104
104
  * - `show` says which repository the project has (`--json` for agents);
105
105
  * - `delete` removes it and its history (owner only).
106
106
  *
107
+ * CI deploys from GitHub Actions (OpenSpec platform-oauth-oidc), owner only:
108
+ *
109
+ * - `link` links a GitHub repository outside Canopy (a Canopy repository needs none);
110
+ * - `unlink` removes that link;
111
+ * - `branches` sets the live and staging branches CI deploys from;
112
+ * - `deploys` turns CI deploys on or off without unlinking.
113
+ *
107
114
  * Setting up a folder points a remote at the repository and registers
108
115
  * `canopy repo credential` as git's credential helper for that one
109
116
  * repository. No token is ever written
@@ -208,16 +215,26 @@ const showCommand = defineCommand({
208
215
  return;
209
216
  }
210
217
  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 {
218
+ else if (repo.kind === "linked") {
219
+ console.log(`${slug} is linked to ${repo.host ? `${repo.host}/` : ""}${repo.linked_repository}.`);
220
+ printCiDeploys(repo);
221
+ } else {
213
222
  console.log(`${slug} has a Canopy git repository.`);
214
223
  console.log(` remote ${repo.remote}`);
215
224
  console.log(` created ${repo.created_at ? formatDateTime(repo.created_at) : "—"}`);
216
225
  console.log(` last push ${repo.last_push_at ? formatDateTime(repo.last_push_at) : "never"}`);
226
+ printCiDeploys(repo);
217
227
  console.log(`\nClone it with \`canopy repo clone ${slug}\`.`);
218
228
  }
219
229
  })
220
230
  });
231
+ /** The CI lines of `repo show`: both branches and whether CI deploys are on. */
232
+ function printCiDeploys(repo) {
233
+ if (repo.deploys_enabled === void 0 || repo.deploys_enabled === null) return;
234
+ console.log(` live ${repo.ref ?? "refs/heads/main"}`);
235
+ console.log(` staging ${repo.staging_ref ?? "—"}`);
236
+ console.log(` CI deploys ${repo.deploys_enabled ? "on" : "off"}`);
237
+ }
221
238
  const createCommand = defineCommand({
222
239
  meta: {
223
240
  name: "create",
@@ -380,13 +397,159 @@ async function readStdin() {
380
397
  for await (const chunk of process.stdin) chunks.push(chunk);
381
398
  return Buffer.concat(chunks).toString("utf8");
382
399
  }
400
+ /** Plain words for the link routes' errors; `invalid_host` here is a GitHub host. */
401
+ const LINK_FIXES = {
402
+ owner_only: "Only the project owner (or a platform admin) can change where CI deploys come from.",
403
+ repo_exists: "This project has a Canopy repository, and CI deploys from it without a link. Set its branches with `canopy repo branches`.",
404
+ invalid_host: "Link a repository on github.com or this site's GitHub Enterprise host (e.g. monks.ghe.com/monks/my-tool).",
405
+ repository_not_linked: "Link a repository first with `canopy repo link <owner/name>`.",
406
+ not_linked: "This project has no linked GitHub repository — `canopy repo show` says what it has."
407
+ };
408
+ /** `main` → `refs/heads/main`; a full ref passes through. */
409
+ function toRef(value) {
410
+ const trimmed = value.trim();
411
+ return trimmed.startsWith("refs/") ? trimmed : `refs/heads/${trimmed}`;
412
+ }
413
+ /** `refs/heads/develop` → `develop`, for the workflow's `on.push.branches`. */
414
+ const branchName = (ref) => ref.replace(/^refs\/heads\//, "");
415
+ /** The GitHub repository the folder's `origin` remote points at, if any. */
416
+ async function githubOrigin(dir) {
417
+ if (!await isWorkingCopy(dir).catch(() => false)) return null;
418
+ const origin = (await remotes(dir).catch(() => /* @__PURE__ */ new Map())).get("origin");
419
+ return origin ? parseGithubRemote(origin) : null;
420
+ }
421
+ function printLink(slug, link) {
422
+ console.log(` repository ${link.host}/${link.repository}`);
423
+ console.log(` live ${link.ref}`);
424
+ console.log(` staging ${link.staging_ref ?? "—"}`);
425
+ console.log(` CI deploys ${link.deploys_enabled ? "on" : "off"}`);
426
+ if (link.staging_ref) p.log.info(`Pushes to ${branchName(link.staging_ref)} deploy ${slug}'s staging channel once the workflow runs on them: add "${branchName(link.staging_ref)}" to on.push.branches in .github/workflows/canopy-deploy.yml.`);
427
+ }
383
428
  const repoCommand = defineCommand({
384
429
  meta: {
385
430
  name: "repo",
386
- description: "The project's git repository (clone, connect, create)"
431
+ description: "The project's git repository (clone, connect, create, link CI deploys)"
387
432
  },
388
433
  subCommands: {
389
434
  show: showCommand,
435
+ link: defineCommand({
436
+ meta: {
437
+ name: "link",
438
+ description: "Link a GitHub repository so its pushes deploy the project (owner only)"
439
+ },
440
+ args: {
441
+ repository: {
442
+ type: "positional",
443
+ description: "[<host>/]<owner/name> — defaults to this folder's origin remote",
444
+ required: false
445
+ },
446
+ ...projectFlagArgs,
447
+ ref: {
448
+ type: "string",
449
+ description: "Live branch (default main)"
450
+ },
451
+ "staging-ref": {
452
+ type: "string",
453
+ description: "Staging branch: its pushes deploy to the staging channel only"
454
+ }
455
+ },
456
+ run: reportErrors("repo link", async ({ args }) => {
457
+ const { dir, slug } = resolveProject({ project: args.project });
458
+ let target;
459
+ if (args.repository) {
460
+ target = parseRepositoryArg(args.repository);
461
+ if (!target) throw new Error(`"${args.repository}" is not a repository — pass <owner/name> or <host>/<owner/name>, e.g. monks/my-tool.`);
462
+ } else {
463
+ target = await githubOrigin(dir);
464
+ if (!target) throw new Error("This folder has no GitHub origin remote to link — pass the repository: `canopy repo link <owner/name>`.");
465
+ }
466
+ const link = await withContext(apiRequest("PUT", `/projects/${slug}/github`, {
467
+ repository: target.repository,
468
+ ...target.host && { host: target.host },
469
+ ...args.ref && { ref: toRef(args.ref) },
470
+ ...args["staging-ref"] && { staging_ref: toRef(args["staging-ref"]) }
471
+ }), {
472
+ action: `link ${slug} to ${target.repository}`,
473
+ fixes: LINK_FIXES
474
+ });
475
+ p.log.success(`Linked ${slug} to ${link.host}/${link.repository} @ ${link.ref}. A push there now deploys ${slug}.`);
476
+ printLink(slug, link);
477
+ })
478
+ }),
479
+ unlink: defineCommand({
480
+ meta: {
481
+ name: "unlink",
482
+ description: "Remove the project's GitHub repository link (owner only)"
483
+ },
484
+ args: { ...projectDirArgs },
485
+ run: reportErrors("repo unlink", async ({ args }) => {
486
+ const { slug } = resolveProject(args);
487
+ await withContext(apiRequest("DELETE", `/projects/${slug}/github`), {
488
+ action: `unlink ${slug}'s repository`,
489
+ fixes: LINK_FIXES
490
+ });
491
+ p.log.success(`Unlinked ${slug}. GitHub Actions runs now skip the deploy.`);
492
+ })
493
+ }),
494
+ branches: defineCommand({
495
+ meta: {
496
+ name: "branches",
497
+ description: "Set the live and staging branches CI deploys from (owner only)"
498
+ },
499
+ args: {
500
+ ...projectDirArgs,
501
+ ref: {
502
+ type: "string",
503
+ description: "Live branch, e.g. main"
504
+ },
505
+ "staging-ref": {
506
+ type: "string",
507
+ description: "Staging branch, e.g. develop (`none` removes it)"
508
+ }
509
+ },
510
+ run: reportErrors("repo branches", async ({ args }) => {
511
+ const { slug } = resolveProject(args);
512
+ const stagingArg = args["staging-ref"];
513
+ if (!args.ref && stagingArg === void 0) throw new Error("Pass --ref and/or --staging-ref (see `canopy repo show` for the current ones).");
514
+ const link = await withContext(apiRequest("PATCH", `/projects/${slug}/github`, {
515
+ ...args.ref && { ref: toRef(args.ref) },
516
+ ...stagingArg !== void 0 && { staging_ref: stagingArg === "none" || stagingArg === "" ? null : toRef(stagingArg) }
517
+ }), {
518
+ action: `set ${slug}'s CI branches`,
519
+ fixes: LINK_FIXES
520
+ });
521
+ p.log.success(`CI deploys for ${slug} come from:`);
522
+ printLink(slug, link);
523
+ })
524
+ }),
525
+ deploys: defineCommand({
526
+ meta: {
527
+ name: "deploys",
528
+ description: "Turn CI deploys from GitHub Actions on or off (owner only)"
529
+ },
530
+ args: {
531
+ state: {
532
+ type: "positional",
533
+ description: "on | off",
534
+ required: true
535
+ },
536
+ ...projectFlagArgs
537
+ },
538
+ run: reportErrors("repo deploys", async ({ args }) => {
539
+ if (args.state !== "on" && args.state !== "off") throw new Error(`Pass on or off (got "${args.state}").`);
540
+ const { slug } = resolveProject({ project: args.project });
541
+ const enabled = args.state === "on";
542
+ await withContext(apiRequest("PATCH", `/projects/${slug}/github`, { deploys_enabled: enabled }), {
543
+ action: `turn CI deploys ${args.state} for ${slug}`,
544
+ fixes: LINK_FIXES
545
+ });
546
+ if (enabled) p.log.success(`CI deploys are on for ${slug}: pushes to its branches deploy again.`);
547
+ else {
548
+ p.log.success(`CI deploys are off for ${slug}: GitHub Actions runs skip the deploy with a warning. The repository link stays.`);
549
+ p.log.info("Deploy tokens are not affected — a workflow with CANOPY_DEPLOY_TOKEN still deploys. Revoke them with `canopy token revoke` to stop those too.");
550
+ }
551
+ })
552
+ }),
390
553
  clone: cloneCommand,
391
554
  connect: connectCommand,
392
555
  create: createCommand,
@@ -411,7 +574,7 @@ async function offerRepoOnCreate(slug, dir, flag) {
411
574
  if (flag === void 0) {
412
575
  if (!process.stdin.isTTY || !await reposAvailable()) return null;
413
576
  const origin = await isWorkingCopy(dir).catch(() => false) ? (await remotes(dir)).get("origin") : void 0;
414
- if (origin && /github\.com[:/]/.test(origin)) return null;
577
+ if (origin && parseGithubRemote(origin)) return null;
415
578
  if (!unwrap(await p.confirm({
416
579
  message: "Create a git repository for this project on Canopy?",
417
580
  initialValue: false
@@ -428,4 +591,4 @@ async function offerRepoOnCreate(slug, dir, flag) {
428
591
  }
429
592
  }
430
593
  //#endregion
431
- export { repoCommand as a, offerRepoOnCreate as i, cloneProjectRepo as n, connectFolder as r, cliInvocation as t };
594
+ export { offerRepoOnCreate as a, githubOrigin as i, cloneProjectRepo as n, repoCommand as o, connectFolder as r, toRef as s, cliInvocation as t };
@@ -0,0 +1,2 @@
1
+ import { o as repoCommand } from "./repo-6a7gRQaf.mjs";
2
+ export { repoCommand };
@@ -1,2 +1,2 @@
1
- import { i as membersCommand, o as rolesCommand } from "./roles-UM0Iiws6.mjs";
1
+ import { i as membersCommand, o as rolesCommand } from "./roles-S2D9Id0W.mjs";
2
2
  export { membersCommand, rolesCommand };
@@ -1,7 +1,8 @@
1
- import { l as resolveProject, o as projectDirArgs } from "./site-0i7wAJqg.mjs";
2
- import { a as apiRequest, f as withContext } from "./api-Bl6SG8Oq.mjs";
3
- import { r as reportErrors } from "./ui-CLnICOne.mjs";
1
+ import { c as projectDirArgs, d as resolveProject } from "./site-BL5Ik-yj.mjs";
2
+ import { a as apiRequest, m as withContext } from "./api-DmJrIsE7.mjs";
3
+ import { a as unwrap, i as requireTty, r as reportErrors } from "./ui-26ETk1n8.mjs";
4
4
  import { defineCommand } from "citty";
5
+ import * as p from "@clack/prompts";
5
6
  //#region src/roles.ts
6
7
  /**
7
8
  * canopy roles / canopy members — a project's app roles and its roster from
@@ -51,11 +52,15 @@ function parseAppRoleFlag(value) {
51
52
  const list = (Array.isArray(value) ? value : [value]).flatMap((v) => v.split(",")).map((v) => v.trim()).filter(Boolean);
52
53
  return [...new Set(list)];
53
54
  }
54
- /** The Canopy role named by `--editor` / `--viewer`, refusing both at once. */
55
+ /** The Canopy role named by `--manager` / `--editor` / `--viewer`, refusing more than one. */
55
56
  function canopyRoleFlag(args) {
56
- if (args.editor && args.viewer) throw new Error("Pass --editor or --viewer, not both.");
57
- if (args.viewer) return "viewer";
58
- if (args.editor) return "editor";
57
+ const given = [
58
+ "manager",
59
+ "editor",
60
+ "viewer"
61
+ ].filter((role) => args[role]);
62
+ if (given.length > 1) throw new Error("Pass one of --manager, --editor or --viewer, not both.");
63
+ return given[0];
59
64
  }
60
65
  const appRoleArg = { "app-role": {
61
66
  type: "string",
@@ -203,8 +208,10 @@ const rolesCommand = defineCommand({
203
208
  });
204
209
  const MEMBER_FIXES = {
205
210
  ...ROLE_FIXES,
206
- not_found: "No such person on the project, or you can't manage it — only the owner adds and removes people.",
207
- forbidden: "Only the project's owner changes who is an editor or a viewer.",
211
+ not_found: "No such person on the project, or you can't manage it — only the owner and managers add and remove people.",
212
+ forbidden: "Only the owner makes someone a manager or changes a manager; managers handle editors and viewers.",
213
+ owner_only: "Only the project's owner can hand it over — an admin can reassign it from the dashboard.",
214
+ invalid_new_owner: "Hand the project to someone already on it as a manager or editor — add them first with `canopy members add`.",
208
215
  already_member: "They're already on the project — change them with `canopy members set`.",
209
216
  already_invited: "They already have a pending invite — change it with `canopy members set`.",
210
217
  email_domain_not_allowed: "Only colleagues who can sign in to Canopy can be added.",
@@ -218,7 +225,7 @@ const roster = (slug) => withContext(apiRequest("GET", `/projects/${slug}/member
218
225
  const membersCommand = defineCommand({
219
226
  meta: {
220
227
  name: "members",
221
- description: "Manage who is on the project and their roles (list / add / set / remove)"
228
+ description: "Manage who is on the project and their roles (list / add / set / remove / transfer)"
222
229
  },
223
230
  subCommands: {
224
231
  list: defineCommand({
@@ -250,7 +257,7 @@ const membersCommand = defineCommand({
250
257
  add: defineCommand({
251
258
  meta: {
252
259
  name: "add",
253
- description: "Add someone as an editor (default) or viewer; invites them if they haven't signed in yet"
260
+ description: "Add someone as an editor (default), viewer or manager; invites them if they haven't signed in yet"
254
261
  },
255
262
  args: {
256
263
  email: {
@@ -266,6 +273,10 @@ const membersCommand = defineCommand({
266
273
  type: "boolean",
267
274
  description: "May manage the project (the default)"
268
275
  },
276
+ manager: {
277
+ type: "boolean",
278
+ description: "May manage the project and its editors and viewers (owner only)"
279
+ },
269
280
  ...appRoleArg,
270
281
  ...projectDirArgs
271
282
  },
@@ -288,7 +299,7 @@ const membersCommand = defineCommand({
288
299
  set: defineCommand({
289
300
  meta: {
290
301
  name: "set",
291
- description: "Change someone's Canopy role (owner only) and/or app roles (editors too)"
302
+ description: "Change someone's Canopy role (owner and managers) and/or app roles (editors too)"
292
303
  },
293
304
  args: {
294
305
  email: {
@@ -304,6 +315,10 @@ const membersCommand = defineCommand({
304
315
  type: "boolean",
305
316
  description: "Make them an editor"
306
317
  },
318
+ manager: {
319
+ type: "boolean",
320
+ description: "Make them a manager (owner only)"
321
+ },
307
322
  ...appRoleArg,
308
323
  "clear-app-roles": {
309
324
  type: "boolean",
@@ -315,7 +330,7 @@ const membersCommand = defineCommand({
315
330
  const { slug } = resolveProject(args);
316
331
  const role = canopyRoleFlag(args);
317
332
  const appRoles = args["clear-app-roles"] ? [] : appRolesOf(args);
318
- if (role === void 0 && appRoles === void 0) throw new Error("Nothing to change — pass --editor, --viewer, --app-role or --clear-app-roles.");
333
+ if (role === void 0 && appRoles === void 0) throw new Error("Nothing to change — pass --manager, --editor, --viewer, --app-role or --clear-app-roles.");
319
334
  const body = {
320
335
  ...role && { role },
321
336
  ...appRoles && { app_roles: appRoles }
@@ -372,6 +387,49 @@ const membersCommand = defineCommand({
372
387
  }
373
388
  throw new Error(`${args.email} is not on ${slug}.`);
374
389
  })
390
+ }),
391
+ transfer: defineCommand({
392
+ meta: {
393
+ name: "transfer",
394
+ description: "Hand the project to a manager or editor (owner only); you stay on as a manager"
395
+ },
396
+ args: {
397
+ email: {
398
+ type: "positional",
399
+ description: "The new owner's email, as `members list` shows it",
400
+ required: true
401
+ },
402
+ yes: {
403
+ type: "boolean",
404
+ default: false,
405
+ description: "Skip the confirmation prompt"
406
+ },
407
+ json: {
408
+ type: "boolean",
409
+ default: false,
410
+ description: "Print the result as JSON"
411
+ },
412
+ ...projectDirArgs
413
+ },
414
+ run: reportErrors("members transfer", async ({ args }) => {
415
+ const { slug } = resolveProject(args);
416
+ if (!args.yes) {
417
+ requireTty("pass --yes to hand the project over without confirmation.");
418
+ if (!unwrap(await p.confirm({ message: `Make ${args.email} the owner of "${slug}"? You stay on as a manager and can't undo this yourself.` }))) {
419
+ p.cancel("Aborted.");
420
+ return;
421
+ }
422
+ }
423
+ const res = await withContext(apiRequest("POST", `/projects/${slug}/owner`, { email: args.email }), {
424
+ action: `hand ${slug} to ${args.email}`,
425
+ fixes: MEMBER_FIXES
426
+ });
427
+ if (args.json) {
428
+ console.log(JSON.stringify(res, null, 2));
429
+ return;
430
+ }
431
+ console.log(`${res.owner_email} now owns ${slug}. You're a manager on it.`);
432
+ })
375
433
  })
376
434
  }
377
435
  });
@@ -1,6 +1,6 @@
1
- import { l as resolveProject, s as projectFlagArgs } from "./site-0i7wAJqg.mjs";
2
- import { a as apiRequest, f as withContext } from "./api-Bl6SG8Oq.mjs";
3
- import { r as reportErrors, t as formatDateTime } from "./ui-CLnICOne.mjs";
1
+ import { d as resolveProject, l as projectFlagArgs } from "./site-BL5Ik-yj.mjs";
2
+ import { a as apiRequest, m as withContext } from "./api-DmJrIsE7.mjs";
3
+ import { r as reportErrors, t as formatDateTime } from "./ui-26ETk1n8.mjs";
4
4
  import { defineCommand } from "citty";
5
5
  //#region src/schedule.ts
6
6
  /**
@@ -1,6 +1,6 @@
1
- import { l as resolveProject, o as projectDirArgs } from "./site-0i7wAJqg.mjs";
2
- import { a as apiRequest, f as withContext } from "./api-Bl6SG8Oq.mjs";
3
- import { a as unwrap, r as reportErrors, t as formatDateTime } from "./ui-CLnICOne.mjs";
1
+ import { c as projectDirArgs, d as resolveProject } from "./site-BL5Ik-yj.mjs";
2
+ import { a as apiRequest, m as withContext } from "./api-DmJrIsE7.mjs";
3
+ import { a as unwrap, r as reportErrors, t as formatDateTime } from "./ui-26ETk1n8.mjs";
4
4
  import { defineCommand } from "citty";
5
5
  import * as p from "@clack/prompts";
6
6
  //#region src/secrets.ts
@@ -259,6 +259,55 @@ function tryResolveProject(args) {
259
259
  slug: args.project ?? tryReadCanopyJson(dir)?.slug
260
260
  };
261
261
  }
262
+ const GITHUB_HOST = /^(?:github\.com|[a-z0-9][a-z0-9-]*\.ghe\.com)$/;
263
+ /**
264
+ * The GitHub repository a git remote URL points at, or null when it is not on
265
+ * github.com or a `*.ghe.com` host (OpenSpec platform-oauth-oidc). Whether the
266
+ * host is one this site trusts is the API's call, not ours.
267
+ *
268
+ * git@monks.ghe.com:monks/my-tool.git
269
+ * ssh://git@github.com/monks/my-tool.git
270
+ * https://github.com/monks/my-tool(.git)
271
+ */
272
+ function parseGithubRemote(url) {
273
+ const trimmed = url.trim();
274
+ const scp = /^[\w.-]+@([^:/]+):([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(trimmed);
275
+ let host;
276
+ let parts;
277
+ if (scp) {
278
+ host = scp[1];
279
+ parts = [scp[2], scp[3]];
280
+ } else try {
281
+ const parsed = new URL(trimmed);
282
+ if (![
283
+ "https:",
284
+ "http:",
285
+ "ssh:",
286
+ "git:"
287
+ ].includes(parsed.protocol)) return null;
288
+ host = parsed.hostname;
289
+ parts = parsed.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "").split("/");
290
+ } catch {
291
+ return null;
292
+ }
293
+ host = host?.toLowerCase();
294
+ if (!host || !GITHUB_HOST.test(host) || parts?.length !== 2 || !parts[0] || !parts[1]) return null;
295
+ return {
296
+ host,
297
+ repository: `${parts[0]}/${parts[1]}`
298
+ };
299
+ }
300
+ /** `[<host>/]owner/name` as typed on the command line; null when it is neither. */
301
+ function parseRepositoryArg(value) {
302
+ const parts = value.trim().replace(/\.git$/, "").split("/");
303
+ if (parts.some((part) => part === "")) return null;
304
+ if (parts.length === 2) return { repository: `${parts[0]}/${parts[1]}` };
305
+ if (parts.length === 3 && parts[0].includes(".")) return {
306
+ host: parts[0].toLowerCase(),
307
+ repository: `${parts[1]}/${parts[2]}`
308
+ };
309
+ return null;
310
+ }
262
311
  //#endregion
263
312
  //#region src/site.ts
264
313
  /**
@@ -300,4 +349,4 @@ function siteHeaderLine(cliVersion) {
300
349
  return `canopy ${cliVersion} · ${c.envOverride || !c.profileId ? c.apiUrl : `${c.profileId} (${c.projectDomain ?? new URL(c.apiUrl).hostname})`}`;
301
350
  }
302
351
  //#endregion
303
- export { setActiveProfile as _, projectDialect as a, getBakedProfile as b, projectUrl as c, stagingUrl as d, tryReadCanopyJson as f, readConfigFile as g, listBakedProfiles as h, siteHeaderLine as i, resolveProject as l, clearAllProfileTokens as m, setInvocationProfile as n, projectDirArgs as o, tryResolveProject as p, siteConfig as r, projectFlagArgs as s, projectDir as t, rewriteCanopyJsonSlug as u, upsertProfileAuth as v, isProfileId as x, writeConfigFile as y };
352
+ export { isProfileId as C, getBakedProfile as S, listBakedProfiles as _, parseGithubRemote as a, upsertProfileAuth as b, projectDirArgs as c, resolveProject as d, rewriteCanopyJsonSlug as f, clearAllProfileTokens as g, tryResolveProject as h, siteHeaderLine as i, projectFlagArgs as l, tryReadCanopyJson as m, setInvocationProfile as n, parseRepositoryArg as o, stagingUrl as p, siteConfig as r, projectDialect as s, projectDir as t, projectUrl as u, readConfigFile as v, writeConfigFile as x, setActiveProfile as y };
@@ -1,5 +1,5 @@
1
- import { t as version } from "./package-Bvly8-rB.mjs";
2
- import { r as reportErrors } from "./ui-CLnICOne.mjs";
1
+ import { t as version } from "./package-BdNHq8QV.mjs";
2
+ import { r as reportErrors } from "./ui-26ETk1n8.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { cpSync, existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
5
  import { homedir } from "node:os";
@@ -1,6 +1,6 @@
1
- import { c as projectUrl, d as stagingUrl, l as resolveProject, s as projectFlagArgs } from "./site-0i7wAJqg.mjs";
2
- import { a as apiRequest, f as withContext } from "./api-Bl6SG8Oq.mjs";
3
- import { a as unwrap, i as requireTty, r as reportErrors, t as formatDateTime } from "./ui-CLnICOne.mjs";
1
+ import { d as resolveProject, l as projectFlagArgs, p as stagingUrl, u as projectUrl } from "./site-BL5Ik-yj.mjs";
2
+ import { a as apiRequest, m as withContext } from "./api-DmJrIsE7.mjs";
3
+ import { a as unwrap, i as requireTty, r as reportErrors, t as formatDateTime } from "./ui-26ETk1n8.mjs";
4
4
  import { defineCommand } from "citty";
5
5
  import * as p from "@clack/prompts";
6
6
  //#region src/staging.ts
@@ -1,34 +1,40 @@
1
1
  # Deploy to Canopy on every push to main. GitHub's runners are the build
2
2
  # farm — the platform owns no build infrastructure.
3
3
  #
4
- # Needs a CANOPY_DEPLOY_TOKEN repo secret: create a deploy token in the
5
- # dashboard (project → Settings → Deploy tokens) or with `canopy token
6
- # create`, then add it via `gh secret set CANOPY_DEPLOY_TOKEN` or the repo's
7
- # Settings → Secrets. Until the secret is set, this workflow skips itself
8
- # instead of failing the build.
4
+ # No secret needed: the run proves which repository and branch it comes from
5
+ # with GitHub's own identity token (the id-token permission below), and
6
+ # Canopy deploys it once the project owner has linked this repository:
7
+ #
8
+ # canopy repo link (in this folder: links the origin remote)
9
+ #
10
+ # Until then, every run skips the deploy with a warning instead of failing.
11
+ # The owner can pause CI deploys without editing this file:
12
+ #
13
+ # canopy repo deploys off (and `on` to resume)
14
+ #
15
+ # A staging branch (`canopy repo branches --staging-ref develop`) deploys to
16
+ # the project's staging channel only. Add it to `branches` below, e.g.
17
+ # [main, develop], so pushes to it run this workflow.
9
18
  name: Deploy to Canopy
10
19
 
11
20
  on:
12
21
  push:
13
22
  branches: [main]
14
23
 
24
+ permissions:
25
+ id-token: write
26
+ contents: read
27
+
15
28
  jobs:
16
29
  deploy:
17
30
  runs-on: ubuntu-latest
18
- env:
19
- CANOPY_DEPLOY_TOKEN: ${{ secrets.CANOPY_DEPLOY_TOKEN }}
20
31
  steps:
21
32
  - uses: actions/checkout@v7
22
33
  - uses: actions/setup-node@v7
23
34
  with:
24
35
  node-version: 24
25
36
  - name: Install dependencies
26
- if: env.CANOPY_DEPLOY_TOKEN != ''
27
37
  run: |
28
38
  if [ -f package-lock.json ]; then npm ci; else npm install; fi
29
39
  - name: Deploy
30
- if: env.CANOPY_DEPLOY_TOKEN != ''
31
40
  run: npm exec --yes @mediamonks/canopy-cli deploy
32
- - name: Skipped (no deploy token)
33
- if: env.CANOPY_DEPLOY_TOKEN == ''
34
- run: echo "CANOPY_DEPLOY_TOKEN secret not set — skipping deploy."
@@ -11,4 +11,4 @@ A [Canopy](https://dashboard.thanarie.dev) project — Vite + React. Live
11
11
  - `AGENTS.md` — project guardrails and skill setup for coding agents
12
12
 
13
13
  Push-to-main deploys are prepared in `.github/workflows/canopy-deploy.yml`
14
- (needs a `CANOPY_DEPLOY_TOKEN` repo secret — see the workflow file).
14
+ (no secret: link the repository once with `canopy repo link` — see the workflow file).