@mediamonks/canopy-cli 0.7.37 → 0.7.38

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.
@@ -11,6 +11,7 @@ description: Register a new project on Canopy and scaffold it locally from a sta
11
11
  canopy create # fully interactive
12
12
  canopy create "My Portfolio" --slug my-portfolio --template spa
13
13
  canopy create existing-app --no-scaffold # register only, no files written
14
+ canopy create "My Retro" --from retro-board # start from a template
14
15
  ```
15
16
 
16
17
  ## Arguments & flags
@@ -20,6 +21,7 @@ canopy create existing-app --no-scaffold # register only, no files written
20
21
  - `--template <id>` — starter to scaffold: `static` (plain HTML/CSS/JS, no build step, untyped SDK from the platform — simplest apps), `spa` (Vite + React + TypeScript with the typed SDK from npm — best for real SDK use or growth), or `worker` (`spa` plus a backend worker in `worker/` — for server-side code: secrets, schedules, calling external APIs). A picker appears when omitted on a TTY; `static` is the default otherwise.
21
22
  - `--scaffold` / `--no-scaffold` — whether to write `./<slug>/` from the template. When neither is given, the CLI scaffolds unless the current directory already has a `canopy.json` (in which case it assumes you're registering an existing app and only registers).
22
23
  - `--repo` / `--no-repo` — also create a git repository for the project on Canopy and set up the new folder to push to it (see [`canopy repo`](https://docs.canopy.website/cli/repo)). When neither is given, an interactive run asks (default no) on sites that offer repositories; non-interactive runs create none.
24
+ - `--from <template>` — start from a template project instead of a starter: the new project gets a copy of the template's code in its own git repository, cloned into `./<slug>/`, plus the setup the code needs. The CLI shows what comes along and asks before creating. `--template`, `--scaffold` and `--repo` don't apply. See [Start from a template](https://docs.canopy.website/guides/templates).
23
25
  - `--profile <site>` — global flag (or `CANOPY_PROFILE`): which site to create the project on. The chosen profile is pinned into the scaffolded `canopy.json`.
24
26
 
25
27
  ## How it works
@@ -10,12 +10,14 @@ description: List your Canopy projects with their status, role, and live URLs.
10
10
  ```bash
11
11
  canopy projects
12
12
  canopy projects --all
13
+ canopy projects --templates
13
14
  canopy --profile staging projects
14
15
  ```
15
16
 
16
17
  ## Arguments & flags
17
18
 
18
19
  - `--all` — include projects you can't edit. By default the list shows only projects where you're an owner or editor (admins always see everything, since they can edit everything).
20
+ - `--templates` — list the templates anyone can start a project from, with their owners. See [Start from a template](https://docs.canopy.website/guides/templates).
19
21
  - `--profile <site>` — global flag (or `CANOPY_PROFILE`): each site has its own project registry, so listing on `staging` shows a completely different set than `main`.
20
22
 
21
23
  ## Details
@@ -25,7 +25,8 @@ target directory, or `--project <slug>` to override — and honor the global
25
25
  `--profile <site>` / `CANOPY_PROFILE`.
26
26
 
27
27
  - `list` — print the secret names and when each was last set. Values are
28
- never shown.
28
+ never shown. For a project started from a template, names it still needs a
29
+ value for are listed as missing.
29
30
  - `set <name>` — store a value. Names are `UPPER_SNAKE_CASE`, up to 64
30
31
  characters. The value comes from **stdin** when piped (one trailing newline
31
32
  is trimmed, so `echo` works too), from `--from-env <VAR>` to read an
@@ -0,0 +1,47 @@
1
+ ---
2
+ title: canopy template
3
+ description: Offer a project as a template, or see what a template gives a new project.
4
+ ---
5
+
6
+ `canopy template` controls whether others can start a project from yours, and
7
+ shows what a template gives a new project. To start from one, use
8
+ [`canopy create --from`](https://docs.canopy.website/cli/create).
9
+
10
+ ## Usage
11
+
12
+ ```bash
13
+ canopy template mark [--yes]
14
+ canopy template unmark
15
+ canopy template show <slug>
16
+ ```
17
+
18
+ ## Arguments & flags
19
+
20
+ `mark` and `unmark` resolve the project the usual way: `canopy.json` in the
21
+ target folder, or `--project <slug>`. All of them honour the global
22
+ `--profile <site>` / `CANOPY_PROFILE`.
23
+
24
+ - `mark` — let anyone on the site start a project from this one. Owner only.
25
+ The project needs a Canopy git repository with code pushed to it. Asks for
26
+ confirmation; `--yes` skips it in scripts. Warns when `canopy.json` names no
27
+ migrations folder, because new projects then start without any tables.
28
+ - `unmark` — stop new projects starting from this one. Existing copies are not
29
+ affected.
30
+ - `show <slug>` — what a new project gets from a template: the code, outbound
31
+ hosts, page settings, Google access, schedules (turned off) and secret names.
32
+
33
+ ## Examples
34
+
35
+ ```bash
36
+ canopy template mark
37
+ ```
38
+
39
+ Offers the project in this folder as a template.
40
+
41
+ ```bash
42
+ canopy template show retro-board
43
+ ```
44
+
45
+ Shows what `canopy create --from retro-board` would give you.
46
+
47
+ Read [Start from a template](https://docs.canopy.website/guides/templates) for the bigger picture.
@@ -72,6 +72,11 @@
72
72
  "title": "Keep your code in git",
73
73
  "summary": "Give your project a git repository on Canopy — history, backup and sharing, no GitHub account needed."
74
74
  },
75
+ {
76
+ "slug": "templates",
77
+ "title": "Start from a template",
78
+ "summary": "Begin a project from someone else's working one — a copy of their code and setup, never their data."
79
+ },
75
80
  {
76
81
  "slug": "agent-tools",
77
82
  "title": "Agent tools (MCP)",
@@ -266,6 +271,11 @@
266
271
  "title": "canopy repo",
267
272
  "summary": "Create, clone and set up the project's git repository on Canopy."
268
273
  },
274
+ {
275
+ "slug": "cli-template",
276
+ "title": "canopy template",
277
+ "summary": "Offer a project as a template, or see what a template gives a new project."
278
+ },
269
279
  {
270
280
  "slug": "cli-egress",
271
281
  "title": "canopy egress",
@@ -0,0 +1,84 @@
1
+ ---
2
+ title: Start from a template
3
+ description: Begin a project from someone else's working one — a copy of their code and setup, never their data.
4
+ ---
5
+
6
+ Some projects are worth starting from: a retro board, a feedback form, a small
7
+ dashboard on an internal API. When the owner offers one as a **template**,
8
+ anyone on Canopy can start a project of their own from it. You get a copy of
9
+ the code and the setup the code needs, then change it however you like.
10
+
11
+ Your copy is yours. Later changes to the template don't reach it, and nothing
12
+ you do reaches the template.
13
+
14
+ ## Find one
15
+
16
+ ```bash
17
+ canopy projects --templates
18
+ canopy template show retro-board
19
+ ```
20
+
21
+ `show` lists exactly what a new project gets. In the dashboard, switch on the
22
+ **Templates** filter on the project list; each template has a **Use as
23
+ template** button that shows the same list.
24
+
25
+ ## Start your project
26
+
27
+ ```bash
28
+ canopy create "My Retro" --slug my-retro --from retro-board
29
+ ```
30
+
31
+ Canopy creates the project, gives it its own git repository with the
32
+ template's code in one fresh commit, and clones it into `./my-retro/`. It then
33
+ tells you what is left, usually:
34
+
35
+ ```bash
36
+ cd my-retro
37
+ canopy secrets set OPENAI_KEY
38
+ canopy deploy
39
+ ```
40
+
41
+ The first deploy runs the template's migrations, so your database starts with
42
+ its tables and nothing else.
43
+
44
+ ## What you get, and what you don't
45
+
46
+ You get:
47
+
48
+ - the code in the template's repository, as it is right now, without its
49
+ history;
50
+ - its allowed outbound hosts and page settings;
51
+ - the Google access it asks visitors for (restricted Google access stays off
52
+ until a Canopy admin unlocks it for your project);
53
+ - its schedules, **turned off** — turn each one on when you're ready;
54
+ - the **names** of its secrets, so you know what to fill in. `canopy secrets
55
+ list` shows the ones still missing.
56
+
57
+ You never get its data, stored files, secret values, members, client password,
58
+ budgets, feedback or deploy history. Your project starts with the usual
59
+ defaults for those.
60
+
61
+ ## Offer your project as a template
62
+
63
+ Your project needs a Canopy git repository with your code pushed to it (see
64
+ [Keep your code in git](https://docs.canopy.website/guides/git)). Then:
65
+
66
+ ```bash
67
+ canopy template mark
68
+ ```
69
+
70
+ or **Settings → Template** in the dashboard. Only the owner can do this.
71
+
72
+ Before you do:
73
+
74
+ - **Everyone on the site can copy the code.** Make sure the repository holds no
75
+ keys, passwords or client data.
76
+ - **Put sample data in migrations.** The database never comes along, so any
77
+ starting rows belong in your migrations. Without a migrations folder, new
78
+ projects start with an empty database.
79
+ - **Explain setup in the README.** It's the first thing people read in their
80
+ copy.
81
+
82
+ `canopy template unmark` stops new copies. Projects already started from it
83
+ are not affected. The template's Settings page shows how many projects started
84
+ from it.
@@ -1,4 +1,4 @@
1
- import { t as version } from "./package-BY69Tjq_.mjs";
1
+ import { t as version } from "./package-CdDH35SE.mjs";
2
2
  import { n as siteConfig } from "./site-DM2J10CB.mjs";
3
3
  import { i as latestKnownVersion, r as compareSemver } from "./version-check-CcsA1QjI.mjs";
4
4
  import { r as reportErrors } from "./ui-CHfKV6lG.mjs";
@@ -1,4 +1,4 @@
1
- import { t as version } from "./package-BY69Tjq_.mjs";
1
+ import { t as version } from "./package-CdDH35SE.mjs";
2
2
  import { a as projectDirArgs, c as resolveProject, d as tryResolveProject, n as siteConfig, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
3
3
  import { a as apiUpload, i as apiRequest, r as apiDownload } from "./api-BPEYm9ts.mjs";
4
4
  import { r as reportErrors, t as formatDateTime } from "./ui-CHfKV6lG.mjs";
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { t as version } from "./package-BY69Tjq_.mjs";
2
+ import { t as version } from "./package-CdDH35SE.mjs";
3
3
  import { r as siteHeaderLine, t as setInvocationProfile } from "./site-DM2J10CB.mjs";
4
4
  import { a as notifyOfUpdate, o as runRefresh } from "./version-check-CcsA1QjI.mjs";
5
5
  import { defineCommand, runMain } from "citty";
@@ -66,10 +66,10 @@ const main = defineCommand({
66
66
  login: () => import("./login-CtDWXICZ.mjs").then((m) => m.loginCommand),
67
67
  whoami: () => import("./login-CtDWXICZ.mjs").then((m) => m.whoamiCommand),
68
68
  logout: () => import("./login-CtDWXICZ.mjs").then((m) => m.logoutCommand),
69
- create: () => import("./projects-DPGRI-SI.mjs").then((m) => m.createCommand),
70
- init: () => import("./projects-DPGRI-SI.mjs").then((m) => m.initCommand),
71
- projects: () => import("./projects-DPGRI-SI.mjs").then((m) => m.projectsCommand),
72
- delete: () => import("./projects-DPGRI-SI.mjs").then((m) => m.deleteCommand),
69
+ create: () => import("./projects-Bm4oTodB.mjs").then((m) => m.createCommand),
70
+ init: () => import("./projects-Bm4oTodB.mjs").then((m) => m.initCommand),
71
+ projects: () => import("./projects-Bm4oTodB.mjs").then((m) => m.projectsCommand),
72
+ delete: () => import("./projects-Bm4oTodB.mjs").then((m) => m.deleteCommand),
73
73
  dev: () => import("./dev-D_qzeLng.mjs").then((m) => m.devCommand),
74
74
  deploy: () => import("./deploy-CqCyry4o.mjs").then((m) => m.deployCommand),
75
75
  promote: () => import("./deploy-CqCyry4o.mjs").then((m) => m.promoteCommand),
@@ -78,20 +78,21 @@ const main = defineCommand({
78
78
  download: () => import("./deploy-CqCyry4o.mjs").then((m) => m.downloadCommand),
79
79
  token: () => import("./token-BWnLsWLg.mjs").then((m) => m.tokenCommand),
80
80
  egress: () => import("./egress-ButFsQ2N.mjs").then((m) => m.egressCommand),
81
- secrets: () => import("./secrets-BQYaPSOT.mjs").then((m) => m.secretsCommand),
81
+ secrets: () => import("./secrets-BOKuWBYV.mjs").then((m) => m.secretsCommand),
82
82
  schedule: () => import("./schedule-DdMq5eXC.mjs").then((m) => m.scheduleCommand),
83
- feedback: () => import("./feedback-CIjjiEaj.mjs").then((m) => m.feedbackCommand),
83
+ feedback: () => import("./feedback-C5NKCOXQ.mjs").then((m) => m.feedbackCommand),
84
84
  db: () => import("./db-CbqgjfYz.mjs").then((m) => m.dbCommand),
85
85
  usage: () => import("./usage-Cqqvnzrf.mjs").then((m) => m.usageCommand),
86
86
  ai: () => import("./ai-CjvrcBIz.mjs").then((m) => m.aiCommand),
87
87
  open: () => import("./open-DXo60Ikf.mjs").then((m) => m.openCommand),
88
88
  browse: () => import("./browse-CBelCgnW.mjs").then((m) => m.browseCommand),
89
- docs: () => import("./docs-DvCUaI1O.mjs").then((m) => m.docsCommand),
90
- skill: () => import("./skill-B96cKy8K.mjs").then((m) => m.skillCommand),
91
- upgrade: () => import("./upgrade-SfxmPh3F.mjs").then((m) => m.upgradeCommand),
89
+ docs: () => import("./docs-T1nuu00A.mjs").then((m) => m.docsCommand),
90
+ skill: () => import("./skill-3wOwhzDx.mjs").then((m) => m.skillCommand),
91
+ upgrade: () => import("./upgrade-BJ2dWjCG.mjs").then((m) => m.upgradeCommand),
92
92
  logs: () => import("./logs-BltUi4Gp.mjs").then((m) => m.logsCommand),
93
- mcp: () => import("./mcp-DTUJV4FT.mjs").then((m) => m.mcpCommand),
94
- repo: () => import("./repo-DvrIsubH.mjs").then((m) => m.repoCommand)
93
+ mcp: () => import("./mcp-BwLRcfqz.mjs").then((m) => m.mcpCommand),
94
+ repo: () => import("./repo-DlYX_1tN.mjs").then((m) => m.repoCommand),
95
+ template: () => import("./project-template-DdT0dqpn.mjs").then((m) => m.templateCommand)
95
96
  }
96
97
  });
97
98
  const quiet = process.argv[2] === "mcp" && process.argv[3] === "serve" || process.argv[2] === "repo" && process.argv[3] === "credential";
@@ -1,4 +1,4 @@
1
- import { t as version } from "./package-BY69Tjq_.mjs";
1
+ import { t as version } from "./package-CdDH35SE.mjs";
2
2
  import { d as tryResolveProject, l as stagingUrl, n as siteConfig, s as projectUrl } from "./site-DM2J10CB.mjs";
3
3
  import { i as apiRequest, t as ApiError } from "./api-BPEYm9ts.mjs";
4
4
  import { t as liveDevServer } from "./dev-server-file-Dk9pk4AR.mjs";
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.7.37";
2
+ var version = "0.7.38";
3
3
  //#endregion
4
4
  export { version as t };
@@ -0,0 +1,125 @@
1
+ import { a as projectDirArgs, c as resolveProject } from "./site-DM2J10CB.mjs";
2
+ import { i as apiRequest, o as withContext } from "./api-BPEYm9ts.mjs";
3
+ import { a as unwrap, i as requireTty, r as reportErrors } from "./ui-CHfKV6lG.mjs";
4
+ import { defineCommand } from "citty";
5
+ import * as p from "@clack/prompts";
6
+ /** Plain words for the API's template errors. */
7
+ const TEMPLATE_FIXES = {
8
+ template_not_found: "No template by that name on this site. `canopy projects --templates` lists the templates you can start from.",
9
+ template_empty: "The template's repository has no code yet. Ask its owner to push it first.",
10
+ template_too_large: `The template's files are larger than the 32 MiB a copy can hold. Ask its owner to remove large files from the repository.`,
11
+ repo_required: "A template needs a Canopy git repository. Create one with `canopy repo create`, push the code, then try again.",
12
+ repo_empty: "Push the project's code to its repository first, then try again.",
13
+ owner_only: "Only the project owner can make it a template, or stop it being one.",
14
+ repos_not_configured: "Templates need Canopy git repositories, which are not available on this site yet."
15
+ };
16
+ /** What the new project gets, one line each — shown before creating. */
17
+ function describeCopy(copy) {
18
+ const lines = ["The code in its repository, as one fresh commit (no history)."];
19
+ if (copy.egress_mode === "open") lines.push("Outbound access: open (the backend may call any host).");
20
+ else if (copy.egress_allowlist.length) lines.push(`Outbound hosts allowed: ${copy.egress_allowlist.join(", ")}.`);
21
+ const loosened = [
22
+ !copy.csp_enabled && "Content-Security-Policy off",
23
+ copy.allow_framing && "other sites may frame the app",
24
+ copy.browser_db_access !== "full" && `browser database access: ${copy.browser_db_access}`,
25
+ !copy.feedback_widget && "feedback widget hidden"
26
+ ].filter(Boolean);
27
+ if (loosened.length) lines.push(`Page settings: ${loosened.join("; ")}.`);
28
+ if (copy.google_bundles.length) lines.push(`Google access it asks visitors for: ${copy.google_bundles.join(", ")}.`);
29
+ if (copy.schedules.length) lines.push(`Schedules, turned off: ${copy.schedules.map((s) => `${s.name} (${s.cron})`).join(", ")}.`);
30
+ if (copy.secrets.length) lines.push(`Secret names to fill in (no values): ${copy.secrets.join(", ")}.`);
31
+ lines.push("Not copied: its data, files, members, password, budgets or history.");
32
+ return lines;
33
+ }
34
+ /** What is left to do once the project exists. */
35
+ function nextSteps(slug, copy) {
36
+ const steps = [`cd ${slug}`];
37
+ for (const name of copy.secrets) steps.push(`canopy secrets set ${name}`);
38
+ steps.push("canopy deploy");
39
+ for (const s of copy.schedules) steps.push(`canopy schedule add ${s.name} "${s.cron}" (to turn it on)`);
40
+ if (copy.google_restricted.length) steps.push(`Ask a Canopy admin to unlock restricted Google access (${copy.google_restricted.join(", ")}), then turn it on in the dashboard.`);
41
+ return steps;
42
+ }
43
+ function fetchTemplate(slug) {
44
+ return withContext(apiRequest("GET", `/projects/${slug}/template`), {
45
+ action: `read template ${slug}`,
46
+ fixes: TEMPLATE_FIXES
47
+ });
48
+ }
49
+ const templateCommand = defineCommand({
50
+ meta: {
51
+ name: "template",
52
+ description: "Offer a project as a template (mark, unmark, show)"
53
+ },
54
+ subCommands: {
55
+ mark: defineCommand({
56
+ meta: {
57
+ name: "mark",
58
+ description: "Let anyone on this site start a project from this one"
59
+ },
60
+ args: {
61
+ ...projectDirArgs,
62
+ yes: {
63
+ type: "boolean",
64
+ description: "Skip the confirmation",
65
+ default: false
66
+ }
67
+ },
68
+ run: reportErrors("template mark", async ({ args }) => {
69
+ const { slug } = resolveProject(args);
70
+ if (!args.yes) {
71
+ requireTty("pass --yes to confirm.");
72
+ p.log.info(`Everyone on this site will be able to start a project of their own with a copy of ${slug}'s current code in its repository, plus its setup (outbound hosts, page settings, schedule and secret names). Its data, secret values and members are never copied.`);
73
+ if (!unwrap(await p.confirm({
74
+ message: `Make ${slug} a template?`,
75
+ initialValue: false
76
+ }))) {
77
+ p.cancel("Not marked.");
78
+ return;
79
+ }
80
+ }
81
+ if ((await withContext(apiRequest("PUT", `/projects/${slug}/template`, { enabled: true }), {
82
+ action: `make ${slug} a template`,
83
+ fixes: TEMPLATE_FIXES
84
+ })).warnings.includes("no_migrations")) p.log.warn("canopy.json sets no \"migrations\", so new projects start with an empty database: none of its tables come along. Add migrations to the repository to fix that.");
85
+ p.log.success(`${slug} is a template. Others start from it with \`canopy create <name> --from ${slug}\`.`);
86
+ })
87
+ }),
88
+ unmark: defineCommand({
89
+ meta: {
90
+ name: "unmark",
91
+ description: "Stop new projects starting from this one"
92
+ },
93
+ args: { ...projectDirArgs },
94
+ run: reportErrors("template unmark", async ({ args }) => {
95
+ const { slug } = resolveProject(args);
96
+ await withContext(apiRequest("PUT", `/projects/${slug}/template`, { enabled: false }), {
97
+ action: `stop ${slug} being a template`,
98
+ fixes: TEMPLATE_FIXES
99
+ });
100
+ p.log.success(`${slug} is no longer a template. Projects already started from it are not affected.`);
101
+ })
102
+ }),
103
+ show: defineCommand({
104
+ meta: {
105
+ name: "show",
106
+ description: "Show what a new project gets from a template"
107
+ },
108
+ args: { slug: {
109
+ type: "positional",
110
+ description: "Template slug",
111
+ required: true
112
+ } },
113
+ run: reportErrors("template show", async ({ args }) => {
114
+ const preview = await fetchTemplate(args.slug);
115
+ const t = preview.template;
116
+ console.log(`${t.name} (${t.slug}) — by ${t.owner.name} <${t.owner.email}>`);
117
+ if (t.description) console.log(t.description);
118
+ console.log("");
119
+ for (const line of describeCopy(preview.copies)) console.log(`- ${line}`);
120
+ })
121
+ })
122
+ }
123
+ });
124
+ //#endregion
125
+ export { templateCommand as a, nextSteps as i, describeCopy as n, fetchTemplate as r, TEMPLATE_FIXES as t };
@@ -0,0 +1,2 @@
1
+ import { a as templateCommand } from "./project-template-BEr_tgIY.mjs";
2
+ export { templateCommand };
@@ -1,7 +1,8 @@
1
1
  import { n as siteConfig, s as projectUrl, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
2
- import { i as apiRequest } from "./api-BPEYm9ts.mjs";
2
+ import { i as apiRequest, o as withContext } from "./api-BPEYm9ts.mjs";
3
3
  import { a as unwrap, i as requireTty, r as reportErrors } from "./ui-CHfKV6lG.mjs";
4
- import { r as offerRepoOnCreate } from "./repo-CKIQrrHK.mjs";
4
+ import { i as nextSteps, n as describeCopy, r as fetchTemplate, t as TEMPLATE_FIXES } from "./project-template-BEr_tgIY.mjs";
5
+ import { i as offerRepoOnCreate, n as cloneProjectRepo } from "./repo-CE22HpqZ.mjs";
5
6
  import { defineCommand } from "citty";
6
7
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
7
8
  import { join, relative, resolve, sep } from "node:path";
@@ -111,6 +112,10 @@ const createCommand = defineCommand({
111
112
  type: "boolean",
112
113
  description: "Also create a git repository for the project on Canopy",
113
114
  negativeDescription: "Don't create a git repository (no question asked)"
115
+ },
116
+ from: {
117
+ type: "string",
118
+ description: "Start from a template project: a copy of its code and setup (`canopy projects --templates`)"
114
119
  }
115
120
  },
116
121
  run: reportErrors("create", async ({ args }) => {
@@ -134,6 +139,10 @@ const createCommand = defineCommand({
134
139
  }
135
140
  })).trim();
136
141
  slug ||= slugify(name);
142
+ if (args.from) {
143
+ await createFromTemplate(name, slug, args.from);
144
+ return;
145
+ }
137
146
  const scaffold = shouldScaffoldCreate(args.scaffold);
138
147
  let template = TEMPLATES.find((t) => t.id === (args.template ?? "static"));
139
148
  if (!template) throw new Error(`Unknown template "${args.template}" — available: ${TEMPLATES.map((t) => t.id).join(", ")}.`);
@@ -188,6 +197,45 @@ const createCommand = defineCommand({
188
197
  }
189
198
  })
190
199
  });
200
+ /**
201
+ * `canopy create --from` (project-templates): show what the new project
202
+ * gets, create it with a copy of the template, clone it, list what's left.
203
+ */
204
+ async function createFromTemplate(name, slug, from) {
205
+ const targetDir = resolve(slug);
206
+ if (existsSync(targetDir)) throw new Error(`Directory ./${slug} already exists — remove it, or pick another slug.`);
207
+ const preview = await fetchTemplate(from);
208
+ p.note(describeCopy(preview.copies).join("\n"), `From ${preview.template.name} (by ${preview.template.owner.name}), ${slug} gets`);
209
+ if (process.stdin.isTTY) {
210
+ if (!unwrap(await p.confirm({
211
+ message: `Create ${slug} from ${from}?`,
212
+ initialValue: true
213
+ }))) {
214
+ p.cancel("Nothing created.");
215
+ return;
216
+ }
217
+ }
218
+ const spin = p.spinner();
219
+ spin.start(`Creating ${slug} from ${from}…`);
220
+ let project;
221
+ try {
222
+ ({project} = await withContext(apiRequest("POST", "/projects", {
223
+ slug,
224
+ name,
225
+ from
226
+ }), {
227
+ action: `create ${slug} from ${from}`,
228
+ fixes: TEMPLATE_FIXES
229
+ }));
230
+ } catch (err) {
231
+ spin.error("Create failed.");
232
+ throw err;
233
+ }
234
+ spin.stop(`Created ${project.name} — ${projectUrl(project.slug)}`);
235
+ await cloneProjectRepo(slug, targetDir);
236
+ p.note(nextSteps(slug, preview.copies).join("\n"), `Cloned into ./${slug}/ — next`);
237
+ p.outro(`Live URL (after first deploy): ${projectUrl(project.slug)}`);
238
+ }
191
239
  /** Empty enough to scaffold into: missing, or holding only dotfiles (.git, .agents…). */
192
240
  function isEmptyish(dir) {
193
241
  if (!existsSync(dir)) return true;
@@ -294,13 +342,31 @@ const projectsCommand = defineCommand({
294
342
  name: "projects",
295
343
  description: "List your projects"
296
344
  },
297
- args: { all: {
298
- type: "boolean",
299
- default: false,
300
- description: "Include projects you can't edit"
301
- } },
345
+ args: {
346
+ all: {
347
+ type: "boolean",
348
+ default: false,
349
+ description: "Include projects you can't edit"
350
+ },
351
+ templates: {
352
+ type: "boolean",
353
+ default: false,
354
+ description: "List the templates anyone can start a project from"
355
+ }
356
+ },
302
357
  run: reportErrors("projects", async ({ args }) => {
303
358
  const { projects, is_admin: isAdmin } = await apiRequest("GET", "/projects");
359
+ if (args.templates) {
360
+ const templates = projects.filter((project) => project.is_template);
361
+ if (templates.length === 0) {
362
+ console.log("No templates on this site yet.");
363
+ return;
364
+ }
365
+ const width = Math.max(...templates.map((project) => project.slug.length));
366
+ for (const project of templates) console.log(`${project.slug.padEnd(width)} ${project.name} (${project.owner_email})`);
367
+ console.log(`\nStart from one with \`canopy create <name> --from <template>\`.`);
368
+ return;
369
+ }
304
370
  const mine = args.all || isAdmin ? projects : projects.filter((project) => project.role);
305
371
  if (mine.length === 0) {
306
372
  console.log(projects.length === 0 ? "No projects yet — run `canopy create`." : `No projects of your own yet — run \`canopy create\`, or --all to list all ${projects.length}.`);
@@ -268,20 +268,23 @@ const cloneCommand = defineCommand({
268
268
  }
269
269
  },
270
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}.`);
271
+ const target = await cloneProjectRepo(args.slug, resolve(args.target ?? args.slug));
272
+ p.log.success(`Cloned ${args.slug} into ${target}.`);
283
273
  })
284
274
  });
275
+ /** Clone a project's Canopy repository into `target`, credential helper included. */
276
+ async function cloneProjectRepo(slug, target) {
277
+ const repo = requireCanopyRepo(slug, await fetchRepo(slug));
278
+ await requireGit();
279
+ const code = await gitInteractive([
280
+ "clone",
281
+ ...credentialConfig(repo.remote, currentProfile(), slug).flatMap(([key, values]) => values.flatMap((value) => ["-c", `${key}=${value}`])),
282
+ repo.remote,
283
+ target
284
+ ]);
285
+ if (code !== 0) throw new Error(`git clone failed (exit ${code}).`);
286
+ return target;
287
+ }
285
288
  const deleteCommand = defineCommand({
286
289
  meta: {
287
290
  name: "delete",
@@ -420,4 +423,4 @@ async function offerRepoOnCreate(slug, dir, flag) {
420
423
  }
421
424
  }
422
425
  //#endregion
423
- export { repoCommand as i, connectFolder as n, offerRepoOnCreate as r, cliInvocation as t };
426
+ export { repoCommand as a, offerRepoOnCreate as i, cloneProjectRepo as n, connectFolder as r, cliInvocation as t };
@@ -0,0 +1,2 @@
1
+ import { a as repoCommand } from "./repo-CE22HpqZ.mjs";
2
+ export { repoCommand };
@@ -52,13 +52,14 @@ const secretsCommand = defineCommand({
52
52
  args: { ...projectDirArgs },
53
53
  run: reportErrors("secrets list", async ({ args }) => {
54
54
  const { slug } = resolveProject(args);
55
- const { secrets } = await apiRequest("GET", `/projects/${slug}/secrets`);
56
- if (secrets.length === 0) {
55
+ const { secrets, missing = [] } = await apiRequest("GET", `/projects/${slug}/secrets`);
56
+ if (secrets.length === 0 && missing.length === 0) {
57
57
  console.log(`No secrets set for ${slug} — add one with \`canopy secrets set NAME\`.`);
58
58
  return;
59
59
  }
60
- const width = Math.max(...secrets.map((s) => s.key.length));
60
+ const width = Math.max(...secrets.map((s) => s.key.length), ...missing.map((m) => m.length));
61
61
  for (const s of secrets) console.log(`${s.key.padEnd(width)} updated ${formatDateTime(s.updated_at)}`);
62
+ for (const name of missing) console.log(`${name.padEnd(width)} missing — set it with \`canopy secrets set ${name}\``);
62
63
  })
63
64
  }),
64
65
  set: defineCommand({
@@ -1,4 +1,4 @@
1
- import { t as version } from "./package-BY69Tjq_.mjs";
1
+ import { t as version } from "./package-CdDH35SE.mjs";
2
2
  import { r as reportErrors } from "./ui-CHfKV6lG.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { cpSync, existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
@@ -112,7 +112,7 @@ const upgradeCommand = defineCommand({
112
112
  const spec = `${PACKAGE_NAME}@${args.version ?? "latest"}`;
113
113
  const command = pmCommand(pm);
114
114
  const cmd = installArgs(pm, spec);
115
- const { skillInstalled } = await import("./skill-B96cKy8K.mjs");
115
+ const { skillInstalled } = await import("./skill-3wOwhzDx.mjs");
116
116
  const { homedir } = await import("node:os");
117
117
  const refreshGlobal = skillInstalled(homedir());
118
118
  const refreshRepo = process.cwd() !== homedir() && skillInstalled(process.cwd());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mediamonks/canopy-cli",
3
- "version": "0.7.37",
3
+ "version": "0.7.38",
4
4
  "description": "Canopy CLI — create, develop and deploy Canopy projects.",
5
5
  "bin": {
6
6
  "canopy": "./dist/index.mjs"
@@ -1,2 +0,0 @@
1
- import { i as repoCommand } from "./repo-CKIQrrHK.mjs";
2
- export { repoCommand };