@mediamonks/canopy-cli 0.7.45 → 0.7.47

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/audience-B68YZBGy.mjs +121 -0
  4. package/dist/{browse-B8-0dweq.mjs → browse-BPLWhoFI.mjs} +2 -2
  5. package/dist/{db-BJJGydgL.mjs → db-DpCMc0uz.mjs} +4 -4
  6. package/dist/{deploy-ExrIeX_m.mjs → deploy-BnRQusjJ.mjs} +51 -5
  7. package/dist/{dev-D-vwE8G-.mjs → dev-BmKeIdMi.mjs} +7 -7
  8. package/dist/dev-BvVlq9np.mjs +2 -0
  9. package/dist/docs/ci.md +112 -50
  10. package/dist/docs/cli-audience.md +47 -0
  11. package/dist/docs/cli-create.md +2 -0
  12. package/dist/docs/cli-deploy.md +1 -1
  13. package/dist/docs/cli-repo.md +34 -2
  14. package/dist/docs/cli-token.md +8 -6
  15. package/dist/docs/cli.md +6 -2
  16. package/dist/docs/deploy.md +3 -2
  17. package/dist/docs/index.json +8 -3
  18. package/dist/docs/platform-deploys-and-rollbacks.md +4 -4
  19. package/dist/docs/platform-members-and-access.md +20 -0
  20. package/dist/{docs-Cpky2XNP.mjs → docs-D5gmZRJk.mjs} +4 -4
  21. package/dist/{egress-C2amvqmE.mjs → egress-BM4EpLBh.mjs} +3 -3
  22. package/dist/{feedback-CTV6inRg.mjs → feedback-C6kiYRro.mjs} +5 -5
  23. package/dist/index.mjs +36 -35
  24. package/dist/{local-BThs382Y.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-Cy8EwhhD.mjs → mcp-DuCxfb85.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-Dph4v1Cq.mjs → package-BksVeJZB.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-C7pq9bIx.mjs → roles-CrYf_8wk.mjs} +1 -1
  38. package/dist/{roles-DAPPXf4U.mjs → roles-S2D9Id0W.mjs} +3 -3
  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-Zj19RNo2.mjs → skill-Bz39NhTN.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-B9tCAbre.mjs → upgrade-BjDaghNf.mjs} +2 -2
  51. package/dist/{usage-DGGmTwjJ.mjs → usage-Bpz5B1N1.mjs} +3 -3
  52. package/package.json +1 -1
  53. package/dist/dev-CSimnUP3.mjs +0 -2
  54. package/dist/project-template-715H_lnu.mjs +0 -2
  55. package/dist/repo-DmFs6H9m.mjs +0 -2
@@ -1,5 +1,5 @@
1
- import { a as apiRequest, f as withContext } from "./api-Bl6SG8Oq.mjs";
2
- import { r as reportErrors } from "./ui-CLnICOne.mjs";
1
+ import { a as apiRequest, m as withContext } from "./api-DmJrIsE7.mjs";
2
+ import { r as reportErrors } from "./ui-26ETk1n8.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  //#region src/ai.ts
5
5
  /**
@@ -1,9 +1,11 @@
1
- import { r as siteConfig, t as projectDir, u as rewriteCanopyJsonSlug } from "./site-0i7wAJqg.mjs";
1
+ import { f as rewriteCanopyJsonSlug, m as tryReadCanopyJson, r as siteConfig, t as projectDir } from "./site-BL5Ik-yj.mjs";
2
2
  //#region src/api.ts
3
3
  /**
4
4
  * Authenticated requests to the Canopy control plane. Credentials, in order:
5
5
  * CANOPY_DEPLOY_TOKEN (CI — a project-scoped deploy token, docs/02-decisions.md
6
- * D11), then the CLI token from `canopy login` (~/.canopy/config.json).
6
+ * D11), then — inside GitHub Actions — a short-lived deploy token exchanged
7
+ * for the run's GitHub identity (OpenSpec platform-oauth-oidc), then the CLI
8
+ * token from `canopy login` (~/.canopy/config.json).
7
9
  */
8
10
  /**
9
11
  * Human copy for the error codes the control plane returns (apps/api): `why`
@@ -192,6 +194,38 @@ const ERROR_COPY = {
192
194
  why: "the secret was saved but redeploying the running worker with it failed",
193
195
  fix: "Retry, or redeploy the project (`canopy deploy`) to apply it."
194
196
  },
197
+ repository_not_linked: {
198
+ why: "the project is not linked to a GitHub repository",
199
+ fix: "Link it with `canopy repo link <owner/name>` (owner only), or set CANOPY_DEPLOY_TOKEN."
200
+ },
201
+ ci_deploys_off: {
202
+ why: "CI deploys are turned off for this project",
203
+ fix: "Turn them back on with `canopy repo deploys on` (owner only)."
204
+ },
205
+ repository_mismatch: {
206
+ why: "this run's repository is not the one linked to the project",
207
+ fix: "Run the workflow from the linked repository, or link this one with `canopy repo link` (`canopy repo show` names the current link)."
208
+ },
209
+ ref_not_allowed: {
210
+ why: "this run's branch is neither the project's live branch nor its staging branch",
211
+ fix: "Deploy from the linked branch, or change it with `canopy repo branches --ref <ref>` (`canopy repo show` names both)."
212
+ },
213
+ invalid_id_token: {
214
+ why: "the platform did not accept this run's GitHub identity — it comes from another GitHub host than the linked repository's, or was minted for another Canopy site",
215
+ fix: "Check `canopy repo show` names this repository's host, and that the workflow deploys to the site canopy.json pins."
216
+ },
217
+ channel_not_allowed: {
218
+ why: "this run is on the project's staging branch, which only deploys to staging",
219
+ fix: "Deploy, promote or roll back live from the live branch, or by hand after `canopy login`."
220
+ },
221
+ invalid_repository: {
222
+ why: "the repository must be written as owner/name",
223
+ fix: "Pass it like `canopy repo link monks/my-tool` (or `monks.ghe.com/monks/my-tool` to name the host)."
224
+ },
225
+ invalid_ref: {
226
+ why: "branches are full git refs, and the staging branch must differ from the live one",
227
+ fix: "Pass e.g. `--ref refs/heads/main --staging-ref refs/heads/develop`."
228
+ },
195
229
  slug_unchanged: {
196
230
  why: "the project already has that slug",
197
231
  fix: "Pass the new slug you want, e.g. `canopy rename my-new-name`."
@@ -369,31 +403,112 @@ async function withContext(request, context) {
369
403
  throw err;
370
404
  }
371
405
  }
372
- /** Resolve the bearer token: CI deploy token first (D11), then the login token. */
373
- function credentials() {
406
+ /**
407
+ * Resolve the bearer token: CI deploy token first (D11), then the GitHub
408
+ * Actions exchange on a runner that may request an identity token, then the
409
+ * login token. The exchange comes before the "Not logged in" check because a
410
+ * runner has no ~/.canopy/config.json.
411
+ */
412
+ async function credentials() {
374
413
  const config = siteConfig();
375
414
  const deployToken = process.env["CANOPY_DEPLOY_TOKEN"];
376
- const token = deployToken || config.token;
377
- if (!token) throw new Error("Not logged in — run `canopy login` first.");
415
+ if (deployToken) return {
416
+ apiUrl: config.apiUrl,
417
+ token: deployToken,
418
+ isDeployToken: true,
419
+ kind: "deploy-token"
420
+ };
421
+ if (onGithubActions()) {
422
+ if (process.env["ACTIONS_ID_TOKEN_REQUEST_URL"] || !config.token) return {
423
+ ...await githubActionsCredentials(),
424
+ isDeployToken: true,
425
+ kind: "github-actions"
426
+ };
427
+ }
428
+ if (!config.token) throw new Error("Not logged in — run `canopy login` first.");
378
429
  return {
379
430
  apiUrl: config.apiUrl,
380
- token,
381
- isDeployToken: Boolean(deployToken)
431
+ token: config.token,
432
+ isDeployToken: false,
433
+ kind: "login"
382
434
  };
383
435
  }
436
+ /** Running inside a GitHub Actions job (the runner sets GITHUB_ACTIONS=true). */
437
+ function onGithubActions(env = process.env) {
438
+ return Boolean(env["GITHUB_ACTIONS"]);
439
+ }
440
+ let exchange;
441
+ /**
442
+ * Trade this GitHub Actions run's OIDC token for a short-lived Canopy deploy
443
+ * token (design D-I). Once per process: every later request reuses it, and a
444
+ * failure stays a failure. The audience is the resolved site's API origin, so
445
+ * the token cannot be replayed on another Canopy site (D-B). `slug` is the
446
+ * project to deploy; it defaults to canopy.json's.
447
+ */
448
+ function githubActionsCredentials(slug) {
449
+ exchange ??= exchangeGithubIdentity(slug);
450
+ return exchange;
451
+ }
452
+ async function exchangeGithubIdentity(slug) {
453
+ const { apiUrl } = siteConfig();
454
+ const project = slug ?? tryReadCanopyJson(projectDir())?.slug;
455
+ if (!project) throw new Error("No project to deploy — run from a directory with a canopy.json naming its slug, or pass --project.");
456
+ const requestUrl = process.env["ACTIONS_ID_TOKEN_REQUEST_URL"];
457
+ const requestToken = process.env["ACTIONS_ID_TOKEN_REQUEST_TOKEN"];
458
+ if (!requestUrl || !requestToken) throw new Error("This GitHub Actions job cannot prove which repository it runs from. Add `permissions: id-token: write` (and `contents: read`) to the workflow or job, or set CANOPY_DEPLOY_TOKEN.");
459
+ const audience = new URL(apiUrl).origin;
460
+ const oidcUrl = new URL(requestUrl);
461
+ oidcUrl.searchParams.set("audience", audience);
462
+ const oidc = await fetch(oidcUrl, { headers: { authorization: `Bearer ${requestToken}` } });
463
+ const idToken = oidc.ok ? (await oidc.json()).value : void 0;
464
+ if (!idToken) throw new Error(`GitHub did not issue an identity token for this run (HTTP ${oidc.status}). Check the workflow has \`permissions: id-token: write\`.`);
465
+ const path = "/auth/github-actions";
466
+ const response = await fetch(`${apiUrl}/control/v1${path}`, {
467
+ method: "POST",
468
+ headers: {
469
+ ...commonHeaders(),
470
+ "content-type": "application/json"
471
+ },
472
+ body: JSON.stringify({
473
+ id_token: idToken,
474
+ project
475
+ })
476
+ });
477
+ const payload = await response.json().catch(() => ({}));
478
+ if (!response.ok || !payload.token) {
479
+ const err = new ApiError(response.status, payload.error ?? `http_${response.status}`, "POST", path, payload.detail, response.headers.get("x-canopy-request-id") ?? void 0);
480
+ err.context = { action: `deploy "${project}" from GitHub Actions` };
481
+ throw err;
482
+ }
483
+ const current = payload.slug ?? project;
484
+ if (current !== project) followRename(response, project);
485
+ return {
486
+ apiUrl,
487
+ token: payload.token,
488
+ slug: current,
489
+ channel: payload.channel === "staging" ? "staging" : "live",
490
+ expiresAt: payload.expires_at ?? ""
491
+ };
492
+ }
493
+ /** The 401 fix line, by what the request carried. */
494
+ const UNAUTHENTICATED_FIX = {
495
+ "deploy-token": "The deploy token was rejected — it may have been revoked. Create a new one in the dashboard (Settings → Deploy tokens) or with `canopy token create`.",
496
+ "github-actions": "This run's short-lived deploy token was refused — CI deploys may have been turned off or the link changed while it ran. Check `canopy repo show`, then re-run the workflow.",
497
+ login: "Your session has expired — run `canopy login` again."
498
+ };
384
499
  /** Turn a non-2xx response into a thrown ApiError (friendlier copy on 401). */
385
- async function fail(response, isDeployToken, method, path) {
500
+ async function fail(response, kind, method, path) {
386
501
  const payload = await response.json().catch(() => ({}));
387
502
  const code = payload.error ?? (response.status === 401 ? "unauthenticated" : `http_${response.status}`);
388
503
  const err = new ApiError(response.status, code, method, path, payload.detail, response.headers.get("x-canopy-request-id") ?? void 0);
389
504
  if (response.status === 401) err.context = {
390
505
  action: "authenticate with Canopy",
391
- fixes: { [code]: isDeployToken ? "The deploy token was rejected — it may have been revoked. Create a new one in the dashboard (Settings → Deploy tokens) or with `canopy token create`." : "Your session has expired — run `canopy login` again." }
506
+ fixes: { [code]: UNAUTHENTICATED_FIX[kind] }
392
507
  };
393
508
  throw err;
394
509
  }
395
510
  async function apiRequest(method, path, body, rawContentType, headers) {
396
- const { apiUrl, token, isDeployToken } = credentials();
511
+ const { apiUrl, token, kind } = await credentials();
397
512
  const isForm = body instanceof FormData;
398
513
  const response = await fetch(`${apiUrl}/control/v1${path}`, {
399
514
  method,
@@ -406,7 +521,7 @@ async function apiRequest(method, path, body, rawContentType, headers) {
406
521
  body: body === void 0 ? void 0 : isForm ? body : rawContentType !== void 0 ? body : JSON.stringify(body)
407
522
  });
408
523
  followRename(response, slugInPath(path));
409
- if (!response.ok) return fail(response, isDeployToken, method, path);
524
+ if (!response.ok) return fail(response, kind, method, path);
410
525
  if (response.status === 204) return void 0;
411
526
  return await response.json();
412
527
  }
@@ -415,7 +530,7 @@ async function apiRequest(method, path, body, rawContentType, headers) {
415
530
  * the bytes itself, so `contentType` is a hint, not a claim it trusts.
416
531
  */
417
532
  async function apiUpload(path, bytes, contentType) {
418
- const { apiUrl, token, isDeployToken } = credentials();
533
+ const { apiUrl, token, kind } = await credentials();
419
534
  const response = await fetch(`${apiUrl}/control/v1${path}`, {
420
535
  method: "POST",
421
536
  headers: {
@@ -426,18 +541,18 @@ async function apiUpload(path, bytes, contentType) {
426
541
  body: bytes
427
542
  });
428
543
  followRename(response, slugInPath(path));
429
- if (!response.ok) return fail(response, isDeployToken, "POST", path);
544
+ if (!response.ok) return fail(response, kind, "POST", path);
430
545
  return await response.json();
431
546
  }
432
547
  /** Authenticated GET returning raw bytes (e.g. a deploy zip). */
433
548
  async function apiDownload(path) {
434
- const { apiUrl, token, isDeployToken } = credentials();
549
+ const { apiUrl, token, kind } = await credentials();
435
550
  const response = await fetch(`${apiUrl}/control/v1${path}`, { headers: {
436
551
  ...commonHeaders(),
437
552
  authorization: `Bearer ${token}`
438
553
  } });
439
554
  followRename(response, slugInPath(path));
440
- if (!response.ok) return fail(response, isDeployToken, "GET", path);
555
+ if (!response.ok) return fail(response, kind, "GET", path);
441
556
  return Buffer.from(await response.arrayBuffer());
442
557
  }
443
558
  /**
@@ -446,7 +561,7 @@ async function apiDownload(path) {
446
561
  * re-vouches it (docs/10-cli-sites.md D17).
447
562
  */
448
563
  async function apiDataRequest(method, path, projectSlug, body, rawContentType, channel) {
449
- const { apiUrl, token, isDeployToken } = credentials();
564
+ const { apiUrl, token, kind } = await credentials();
450
565
  const response = await fetch(`${apiUrl}/data/v1${path}`, {
451
566
  method,
452
567
  headers: {
@@ -459,9 +574,9 @@ async function apiDataRequest(method, path, projectSlug, body, rawContentType, c
459
574
  body: body === void 0 ? void 0 : rawContentType !== void 0 ? body : JSON.stringify(body)
460
575
  });
461
576
  followRename(response, projectSlug);
462
- if (!response.ok) return fail(response, isDeployToken, method, path);
577
+ if (!response.ok) return fail(response, kind, method, path);
463
578
  if (response.status === 204) return void 0;
464
579
  return await response.json();
465
580
  }
466
581
  //#endregion
467
- export { apiRequest as a, credentials as c, noticeRenamesOnly as d, withContext as f, apiDownload as i, currentSlug as l, agentHeaders as n, apiUpload as o, apiDataRequest as r, commonHeaders as s, ApiError as t, followRename as u };
582
+ export { apiRequest as a, credentials as c, githubActionsCredentials as d, noticeRenamesOnly as f, apiDownload as i, currentSlug as l, withContext as m, agentHeaders as n, apiUpload as o, onGithubActions as p, apiDataRequest as r, commonHeaders as s, ApiError as t, followRename as u };
@@ -0,0 +1,121 @@
1
+ import { c as projectDirArgs, 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 } from "./ui-26ETk1n8.mjs";
4
+ import { defineCommand } from "citty";
5
+ import * as p from "@clack/prompts";
6
+ //#region src/audience.ts
7
+ /**
8
+ * Project audience (OpenSpec change project-audience): `canopy audience
9
+ * show|set|draft|ready`. Who a project is for and whether it's still a draft
10
+ * are labels on the dashboard's project list, stored on the platform (never
11
+ * in canopy.json) and set through the project update route.
12
+ */
13
+ const KINDS = [
14
+ "everyone",
15
+ "personal",
16
+ "client",
17
+ "department"
18
+ ];
19
+ const NAME_MAX = 40;
20
+ /** `set <kind> [name]` → the update body, or a sentence saying what's wrong. */
21
+ function parseAudience(kind, name) {
22
+ const k = kind.trim().toLowerCase();
23
+ if (!KINDS.includes(k)) return `"${kind}" is not an audience. Use everyone, personal, client <name> or department <name>.`;
24
+ if (k === "client" || k === "department") {
25
+ const n = name?.trim() ?? "";
26
+ if (!n) return `Name the ${k}: canopy audience set ${k} "<name>"`;
27
+ if (n.length > NAME_MAX) return `Keep the ${k} name to ${NAME_MAX} characters.`;
28
+ return {
29
+ kind: k,
30
+ name: n
31
+ };
32
+ }
33
+ if (name?.trim()) return `${k === "everyone" ? "Everyone" : "Personal"} takes no name.`;
34
+ return { kind: k };
35
+ }
36
+ /** One line for people: who it's for, and whether it's a draft. */
37
+ function describeAudience(project) {
38
+ const a = project.audience ?? null;
39
+ const who = a === null ? "is for everyone" : a.kind === "personal" ? "is a personal project" : `is for the ${a.name} ${a.kind === "client" ? "client team" : "department"}`;
40
+ return `${project.slug} ${who}${project.draft ? ", and is still a draft" : ""}.`;
41
+ }
42
+ const FIXES = { invalid_audience: "Use everyone, personal, client <name> or department <name> (names up to 40 characters)." };
43
+ function update(slug, body) {
44
+ return withContext(apiRequest("PATCH", `/projects/${slug}`, body).then((r) => r.project), {
45
+ action: `update ${slug}`,
46
+ fixes: FIXES
47
+ });
48
+ }
49
+ const audienceCommand = defineCommand({
50
+ meta: {
51
+ name: "audience",
52
+ description: "Who the project is for, and whether it's a draft (show, set, draft, ready)"
53
+ },
54
+ subCommands: {
55
+ show: defineCommand({
56
+ meta: {
57
+ name: "show",
58
+ description: "Show who the project is for and whether it's a draft"
59
+ },
60
+ args: { ...projectDirArgs },
61
+ run: reportErrors("audience show", async ({ args }) => {
62
+ const { slug } = resolveProject(args);
63
+ const { project } = await withContext(apiRequest("GET", `/projects/${slug}`), { action: `read ${slug}` });
64
+ console.log(describeAudience(project));
65
+ })
66
+ }),
67
+ set: defineCommand({
68
+ meta: {
69
+ name: "set",
70
+ description: "Say who it's for: everyone, personal, client <name> or department <name>"
71
+ },
72
+ args: {
73
+ kind: {
74
+ type: "positional",
75
+ description: "everyone | personal | client | department",
76
+ required: true
77
+ },
78
+ name: {
79
+ type: "positional",
80
+ description: "The client team or department (client and department only)",
81
+ required: false
82
+ },
83
+ ...projectFlagArgs
84
+ },
85
+ run: reportErrors("audience set", async ({ args }) => {
86
+ const audience = parseAudience(args.kind, args.name);
87
+ if (typeof audience === "string") {
88
+ console.error(audience);
89
+ process.exit(1);
90
+ }
91
+ const { slug } = resolveProject({ project: args.project });
92
+ p.log.success(describeAudience(await update(slug, { audience })));
93
+ p.log.info("It's a label on the project list; who can open the project is unchanged.");
94
+ })
95
+ }),
96
+ draft: defineCommand({
97
+ meta: {
98
+ name: "draft",
99
+ description: "Mark it as not ready yet (greyed out on the list)"
100
+ },
101
+ args: { ...projectDirArgs },
102
+ run: reportErrors("audience draft", async ({ args }) => {
103
+ const { slug } = resolveProject(args);
104
+ p.log.success(describeAudience(await update(slug, { draft: true })));
105
+ })
106
+ }),
107
+ ready: defineCommand({
108
+ meta: {
109
+ name: "ready",
110
+ description: "Mark it as ready (no longer a draft)"
111
+ },
112
+ args: { ...projectDirArgs },
113
+ run: reportErrors("audience ready", async ({ args }) => {
114
+ const { slug } = resolveProject(args);
115
+ p.log.success(describeAudience(await update(slug, { draft: false })));
116
+ })
117
+ })
118
+ }
119
+ });
120
+ //#endregion
121
+ export { audienceCommand };
@@ -1,5 +1,5 @@
1
- import { p as tryResolveProject, r as siteConfig } from "./site-0i7wAJqg.mjs";
2
- import { a as apiRequest, t as ApiError } from "./api-Bl6SG8Oq.mjs";
1
+ import { h as tryResolveProject, r as siteConfig } from "./site-BL5Ik-yj.mjs";
2
+ import { a as apiRequest, t as ApiError } from "./api-DmJrIsE7.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  //#region src/browse.ts
5
5
  /**
@@ -1,7 +1,7 @@
1
- import { l as resolveProject, s as projectFlagArgs } from "./site-0i7wAJqg.mjs";
2
- import { a as apiRequest, f as withContext, r as apiDataRequest } from "./api-Bl6SG8Oq.mjs";
3
- import { a as unwrap, i as requireTty, r as reportErrors } from "./ui-CLnICOne.mjs";
4
- import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-D8NqjC5I.mjs";
1
+ import { d as resolveProject, l as projectFlagArgs } from "./site-BL5Ik-yj.mjs";
2
+ import { a as apiRequest, m as withContext, r as apiDataRequest } from "./api-DmJrIsE7.mjs";
3
+ import { a as unwrap, i as requireTty, r as reportErrors } from "./ui-26ETk1n8.mjs";
4
+ import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-BReMlGWP.mjs";
5
5
  import { defineCommand } from "citty";
6
6
  import { readFileSync, writeFileSync } from "node:fs";
7
7
  import * as p from "@clack/prompts";
@@ -1,7 +1,7 @@
1
- import { c as projectUrl, d as stagingUrl, f as tryReadCanopyJson, l as resolveProject, o as projectDirArgs, r as siteConfig, s as projectFlagArgs } from "./site-0i7wAJqg.mjs";
2
- import { a as apiRequest, f as withContext, i as apiDownload, l as currentSlug, r as apiDataRequest } from "./api-Bl6SG8Oq.mjs";
3
- import { a as unwrap, r as reportErrors, t as formatDateTime } from "./ui-CLnICOne.mjs";
4
- import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-D8NqjC5I.mjs";
1
+ import { c as projectDirArgs, d as resolveProject, l as projectFlagArgs, m as tryReadCanopyJson, p as stagingUrl, r as siteConfig, u as projectUrl } from "./site-BL5Ik-yj.mjs";
2
+ import { a as apiRequest, d as githubActionsCredentials, i as apiDownload, l as currentSlug, m as withContext, p as onGithubActions, r as apiDataRequest, t as ApiError } from "./api-DmJrIsE7.mjs";
3
+ import { a as unwrap, r as reportErrors, t as formatDateTime } from "./ui-26ETk1n8.mjs";
4
+ import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-BReMlGWP.mjs";
5
5
  import { builtinModules } from "node:module";
6
6
  import { defineCommand } from "citty";
7
7
  import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -235,9 +235,18 @@ const deployCommand = defineCommand({
235
235
  },
236
236
  run: reportErrors("deploy", async ({ args }) => {
237
237
  const { dir, slug } = resolveProject(args);
238
- const channel = args.staging ? "staging" : "live";
238
+ let channel = args.staging ? "staging" : "live";
239
239
  const config = tryReadCanopyJson(dir) ?? {};
240
240
  if (config.build && args.build) await runBuild(config.build, dir);
241
+ if (onGithubActions() && !process.env["CANOPY_DEPLOY_TOKEN"]) {
242
+ const exchanged = await exchangeOrSkip(slug);
243
+ if (!exchanged) return;
244
+ p.log.info(`Authenticated as this GitHub Actions run (${exchanged.slug}).`);
245
+ if (exchanged.channel === "staging" && channel === "live") {
246
+ channel = "staging";
247
+ p.log.info("This run is on the project's staging branch — deploying to the staging channel.");
248
+ }
249
+ }
241
250
  const migrations = args.migrate ? readMigrationFiles(dir) : void 0;
242
251
  if (migrations !== void 0 && config.dialect) try {
243
252
  const { dialect } = await apiDataRequest("GET", "/platform", slug);
@@ -396,6 +405,43 @@ function bundleLimitError(files, limits) {
396
405
  }
397
406
  return null;
398
407
  }
408
+ /**
409
+ * Exchange errors that mean "CI deploys are not on here": skip, don't fail.
410
+ * `unauthenticated` is a site whose API predates the exchange — its auth wall
411
+ * answers the unknown route; the exchange itself never says it (a bad token
412
+ * is `invalid_id_token`). A project scaffolded on such a site keeps the
413
+ * yellow skip the old secret-gated workflow gave it until the site catches up.
414
+ */
415
+ const SKIP_CODES = /* @__PURE__ */ new Set([
416
+ "repository_not_linked",
417
+ "ci_deploys_off",
418
+ "unauthenticated"
419
+ ]);
420
+ /**
421
+ * The run's deploy credentials, or null after announcing a skip. A project
422
+ * that is not linked, or whose CI deploys are off, leaves the run yellow
423
+ * rather than red: a workflow annotation saying why and how to change it, and
424
+ * exit 0 without deploying. Every other refusal is a failure.
425
+ */
426
+ async function exchangeOrSkip(slug) {
427
+ try {
428
+ return await githubActionsCredentials(slug);
429
+ } catch (err) {
430
+ if (!(err instanceof ApiError) || !SKIP_CODES.has(err.code)) throw err;
431
+ const message = skipMessage(err.code, slug);
432
+ console.log(`::warning title=Canopy deploy skipped::${message}`);
433
+ p.log.warn(message);
434
+ return null;
435
+ }
436
+ }
437
+ function skipMessage(code, slug) {
438
+ if (code === "ci_deploys_off") return `CI deploys are off for "${slug}", so this run deployed nothing. The project owner can turn them back on with \`canopy repo deploys on\`.`;
439
+ if (code === "unauthenticated") return "This Canopy site doesn't take deploys from GitHub Actions without a token yet, so this run deployed nothing. Set a CANOPY_DEPLOY_TOKEN secret to deploy from CI.";
440
+ const repo = process.env["GITHUB_REPOSITORY"];
441
+ const server = process.env["GITHUB_SERVER_URL"];
442
+ const host = server ? new URL(server).host : void 0;
443
+ return `"${slug}" is not linked to this repository, so this run deployed nothing. The project owner can link it with \`canopy repo link ${repo ? `${host && host !== "github.com" ? `${host}/` : ""}${repo}` : "<owner/name>"}\`, or set a CANOPY_DEPLOY_TOKEN secret.`;
444
+ }
399
445
  /** PUT each file to /data/v1/uploads/<grant> — the grant is the credential. */
400
446
  async function uploadFiles(grant, files) {
401
447
  const { apiUrl } = siteConfig();
@@ -1,9 +1,9 @@
1
- import { a as projectDialect, f as tryReadCanopyJson, l as resolveProject, o as projectDirArgs, r as siteConfig } from "./site-0i7wAJqg.mjs";
2
- import { a as apiRequest, f as withContext, t as ApiError } from "./api-Bl6SG8Oq.mjs";
3
- import { r as reportErrors, t as formatDateTime } from "./ui-CLnICOne.mjs";
4
- import { r as readMigrationFiles } from "./migrations-D8NqjC5I.mjs";
1
+ import { c as projectDirArgs, d as resolveProject, m as tryReadCanopyJson, r as siteConfig, s as projectDialect } from "./site-BL5Ik-yj.mjs";
2
+ import { a as apiRequest, m as withContext, t as ApiError } from "./api-DmJrIsE7.mjs";
3
+ import { r as reportErrors, t as formatDateTime } from "./ui-26ETk1n8.mjs";
4
+ import { r as readMigrationFiles } from "./migrations-BReMlGWP.mjs";
5
5
  import { i as stampWorkerHeaders, n as WORKER_ATTEST_HEADER } from "./src-8vxOBOxW.mjs";
6
- import { a as parseAppRoleFlag } from "./roles-DAPPXf4U.mjs";
6
+ import { a as parseAppRoleFlag } from "./roles-S2D9Id0W.mjs";
7
7
  import { n as writeDevServerRecord } from "./dev-server-file-Dk9pk4AR.mjs";
8
8
  import { createRequire } from "node:module";
9
9
  import { defineCommand } from "citty";
@@ -325,7 +325,7 @@ async function devRemote(flags, slug) {
325
325
  async function devLocal(flags, slug) {
326
326
  let dump;
327
327
  if (flags.pull) {
328
- const { fetchProjectDump } = await import("./local-BThs382Y.mjs");
328
+ const { fetchProjectDump } = await import("./local-C9Bi9f3y.mjs");
329
329
  console.log(`Pulling a fresh export of ${slug}${flags.staging ? " [staging]" : ""}…`);
330
330
  dump = await fetchProjectDump(slug, flags.staging ? "staging" : "live");
331
331
  }
@@ -342,7 +342,7 @@ async function devLocal(flags, slug) {
342
342
  }
343
343
  const workerAttest = randomBytes(24).toString("base64url");
344
344
  const devRoles = await resolveDevRoles(slug, flags.asRoles);
345
- const { createSandbox } = await import("./local-BThs382Y.mjs");
345
+ const { createSandbox } = await import("./local-C9Bi9f3y.mjs");
346
346
  const sandbox = await createSandbox({
347
347
  slug,
348
348
  persistDir: join(flags.dir, ".canopy", "local"),
@@ -0,0 +1,2 @@
1
+ import { n as devCommand } from "./dev-BmKeIdMi.mjs";
2
+ export { devCommand };