@mediamonks/canopy-cli 0.7.32 → 0.7.33
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.
- package/dist/docs/cli-create.md +1 -0
- package/dist/docs/cli-repo.md +87 -0
- package/dist/docs/getting-started.md +3 -0
- package/dist/docs/git.md +86 -0
- package/dist/docs/index.json +10 -0
- package/dist/{docs-CtNH5kbN.mjs → docs-BP5jmD10.mjs} +1 -1
- package/dist/{feedback-_L3vqktY.mjs → feedback-5oPSxAz0.mjs} +1 -1
- package/dist/index.mjs +15 -14
- package/dist/{login-B-m-i8uu.mjs → login-D0J5s_Ex.mjs} +3 -1
- package/dist/{mcp-RcN17Z2v.mjs → mcp-DXfTYVN_.mjs} +1 -1
- package/dist/{package-YGjjin4p.mjs → package-CQIPmdcO.mjs} +1 -1
- package/dist/{projects-CN2YhCZn.mjs → projects-DQAhCZm8.mjs} +13 -1
- package/dist/repo-1RQ1vBMv.mjs +2 -0
- package/dist/repo-Bb9ntNa4.mjs +423 -0
- package/dist/repo-credential-CNDRM-tG.mjs +118 -0
- package/dist/skill/canopy/SKILL.md +7 -1
- package/dist/{skill-CM7R-4Bq.mjs → skill-Dg7dQF0k.mjs} +1 -1
- package/dist/templates/spa/AGENTS.md +13 -0
- package/dist/templates/spa/_gitignore +4 -0
- package/dist/templates/static/AGENTS.md +11 -0
- package/dist/templates/static/_gitignore +4 -0
- package/dist/templates/worker/AGENTS.md +13 -0
- package/dist/templates/worker/_gitignore +3 -0
- package/dist/{upgrade-ChcSZW8i.mjs → upgrade-huJDjhrV.mjs} +1 -1
- package/package.json +1 -1
package/dist/docs/cli-create.md
CHANGED
|
@@ -19,6 +19,7 @@ canopy create existing-app --no-scaffold # register only, no files written
|
|
|
19
19
|
- `--slug <slug>` — the URL slug; the project will live at `https://<slug>.<site-domain>`. Lowercase letters, digits and dashes only. Defaults to a slugified version of the name (prompted on a TTY).
|
|
20
20
|
- `--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
21
|
- `--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
|
+
- `--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.
|
|
22
23
|
- `--profile <site>` — global flag (or `CANOPY_PROFILE`): which site to create the project on. The chosen profile is pinned into the scaffolded `canopy.json`.
|
|
23
24
|
|
|
24
25
|
## How it works
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: canopy repo
|
|
3
|
+
description: Create, clone and set up the project's git repository on Canopy.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
`canopy repo` manages the project's git repository. A project has at most one: a
|
|
7
|
+
Canopy repository, which Canopy keeps for it, or its own GitHub repository.
|
|
8
|
+
Once a folder is set up, you use plain git. The CLI signs git in on every push
|
|
9
|
+
and pull, so you never handle a password or token.
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
canopy repo show [--json]
|
|
15
|
+
canopy repo create
|
|
16
|
+
canopy repo clone <slug> [folder]
|
|
17
|
+
canopy repo connect
|
|
18
|
+
canopy repo delete [--yes]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Arguments & flags
|
|
22
|
+
|
|
23
|
+
`show`, `create`, `connect` and `delete` resolve the project the usual way:
|
|
24
|
+
`canopy.json` in the target folder, or `--project <slug>`. All of them honour
|
|
25
|
+
the global `--profile <site>` / `CANOPY_PROFILE`.
|
|
26
|
+
|
|
27
|
+
- `show` — whether the project has a repository, and if so its remote, when it
|
|
28
|
+
was created and when it was last pushed to.
|
|
29
|
+
- `--json` — the same as JSON, for scripts and agents.
|
|
30
|
+
- `create` — create the project's Canopy repository. Owners, editors and
|
|
31
|
+
platform admins can. Run it in the project's folder and the folder is set up
|
|
32
|
+
too.
|
|
33
|
+
- `clone <slug> [folder]` — clone the project's repository into a new folder
|
|
34
|
+
(the slug by default), ready to push.
|
|
35
|
+
- `connect` — set up an existing folder.
|
|
36
|
+
- If the folder isn't a git repository yet, it is initialised on branch `main`.
|
|
37
|
+
- If it has no `origin`, the Canopy repository becomes `origin`.
|
|
38
|
+
- If `origin` points somewhere else, the Canopy repository is added as
|
|
39
|
+
`canopy`.
|
|
40
|
+
- `delete` — delete the repository and all its history. Owner only. Asks you to
|
|
41
|
+
type the slug.
|
|
42
|
+
- `--yes` — skip that question.
|
|
43
|
+
|
|
44
|
+
## How it works
|
|
45
|
+
|
|
46
|
+
Setting up a folder (`create`, `clone`, `connect`) does two things:
|
|
47
|
+
|
|
48
|
+
1. It points a remote at the project's repository.
|
|
49
|
+
2. It registers the CLI as git's credential helper for that one repository.
|
|
50
|
+
|
|
51
|
+
Nothing secret is written into the folder or its git settings. On each push or
|
|
52
|
+
pull, git asks the CLI for credentials. The CLI exchanges your `canopy login`
|
|
53
|
+
for a short-lived token that only works for this repository, keeps it in
|
|
54
|
+
`~/.canopy` for up to an hour, and gets a new one when needed. `canopy logout`
|
|
55
|
+
throws them away.
|
|
56
|
+
|
|
57
|
+
Pushing doesn't deploy. Run `canopy deploy` for that.
|
|
58
|
+
|
|
59
|
+
## Examples
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
canopy repo create
|
|
63
|
+
git add -A && git commit -m "First version" && git push -u origin main
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Creates the repository for the project in this folder and pushes the code.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
canopy repo clone my-tool
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Clones a teammate's project into `./my-tool`, ready to push.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
canopy repo show --json
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"kind": "canopy",
|
|
81
|
+
"remote": "https://monks.ghe.com/canopy/my-tool.git",
|
|
82
|
+
"created_at": "2026-10-21T09:12:00Z",
|
|
83
|
+
"last_push_at": "2026-10-21T09:15:42Z"
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Read the [git guide](https://docs.canopy.website/guides/git) for the bigger picture.
|
|
@@ -93,6 +93,9 @@ Then open the live site:
|
|
|
93
93
|
canopy open
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
+
Want your code in git too, without a GitHub account? `canopy repo create` gives
|
|
97
|
+
the project a repository on Canopy — see [Keep your code in git](https://docs.canopy.website/guides/git).
|
|
98
|
+
|
|
96
99
|
## Where to go next
|
|
97
100
|
|
|
98
101
|
Everything the CLI can do is documented and available offline. List the topics:
|
package/dist/docs/git.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Keep your code in git
|
|
3
|
+
description: Give your project a git repository on Canopy — history, backup and sharing, no GitHub account needed.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Every project can have one git repository. If you already use GitHub, keep using
|
|
7
|
+
it. If you don't have a GitHub account, Canopy can keep a private repository for
|
|
8
|
+
your project instead. Your code then has history and a backup, and teammates and
|
|
9
|
+
their agents can pick it up.
|
|
10
|
+
|
|
11
|
+
A Canopy repository is for storing code. Pushing to it doesn't deploy anything —
|
|
12
|
+
`canopy deploy` still does that.
|
|
13
|
+
|
|
14
|
+
## Create one
|
|
15
|
+
|
|
16
|
+
From your project folder:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
canopy repo create
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
That creates the repository and sets up the folder, so from then on it is plain
|
|
23
|
+
git:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
git add -A
|
|
27
|
+
git commit -m "First version"
|
|
28
|
+
git push -u origin main
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
New projects can get one straight away with `canopy create my-tool --repo`. You
|
|
32
|
+
can also create one from the dashboard, on the project's **Deploys** tab.
|
|
33
|
+
|
|
34
|
+
## Work on it from another machine (or hand it to a teammate)
|
|
35
|
+
|
|
36
|
+
Anyone on the project can clone it:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
canopy repo clone my-tool
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Already have the code in a folder? Set that folder up instead:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
canopy repo connect
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
If the folder already pushes somewhere else (say your own GitHub), that stays
|
|
49
|
+
`origin` and the Canopy repository is added as a second remote called `canopy`.
|
|
50
|
+
|
|
51
|
+
## Signing in
|
|
52
|
+
|
|
53
|
+
You never type a password or paste a token. The Canopy CLI signs git in for you
|
|
54
|
+
on every push and pull, using your `canopy login`. When that sign-in expires, git
|
|
55
|
+
stops and tells you to run `canopy login` again.
|
|
56
|
+
|
|
57
|
+
Who can do what follows the project's members:
|
|
58
|
+
|
|
59
|
+
- Owners and editors can pull and push.
|
|
60
|
+
- Platform admins can pull.
|
|
61
|
+
- Nobody else gets in.
|
|
62
|
+
|
|
63
|
+
Remove someone from the project and they can no longer push or pull, even from
|
|
64
|
+
a copy they already cloned.
|
|
65
|
+
|
|
66
|
+
## Git apps (GitHub Desktop, VS Code, Tower, …)
|
|
67
|
+
|
|
68
|
+
Set the folder up once with `canopy repo clone` or `canopy repo connect` in a
|
|
69
|
+
terminal. After that, git apps that use the git installed on your computer push
|
|
70
|
+
and pull as usual. Apps that bring their own git or ask for a password can't
|
|
71
|
+
sign in to a Canopy repository; use the terminal for those.
|
|
72
|
+
|
|
73
|
+
## Good to know
|
|
74
|
+
|
|
75
|
+
- **Keep secrets out.** Files like `.env` and `.dev.vars` are ignored by the
|
|
76
|
+
project templates; secrets belong in `canopy secrets`.
|
|
77
|
+
- **There is no branch protection.** Anyone who can push can rewrite history, so
|
|
78
|
+
avoid force-pushes on `main`.
|
|
79
|
+
- **Deleting is for good.** `canopy repo delete` (owner only) removes the
|
|
80
|
+
repository and all its history. Deleting the project deletes its repository
|
|
81
|
+
too.
|
|
82
|
+
- **Moving to your own GitHub later** is ordinary git: create an empty
|
|
83
|
+
repository there, then `git remote add github <its url>` and
|
|
84
|
+
`git push github --all`.
|
|
85
|
+
|
|
86
|
+
See the [`canopy repo` reference](https://docs.canopy.website/cli/repo) for every command and flag.
|
package/dist/docs/index.json
CHANGED
|
@@ -67,6 +67,11 @@
|
|
|
67
67
|
"title": "CI and deploy tokens",
|
|
68
68
|
"summary": "Push-to-main deploys using a project-scoped deploy token."
|
|
69
69
|
},
|
|
70
|
+
{
|
|
71
|
+
"slug": "git",
|
|
72
|
+
"title": "Keep your code in git",
|
|
73
|
+
"summary": "Give your project a git repository on Canopy — history, backup and sharing, no GitHub account needed."
|
|
74
|
+
},
|
|
70
75
|
{
|
|
71
76
|
"slug": "agent-tools",
|
|
72
77
|
"title": "Agent tools (MCP)",
|
|
@@ -251,6 +256,11 @@
|
|
|
251
256
|
"title": "canopy token",
|
|
252
257
|
"summary": "Create, list, and revoke project-scoped deploy tokens for CI and push-to-main deploys."
|
|
253
258
|
},
|
|
259
|
+
{
|
|
260
|
+
"slug": "cli-repo",
|
|
261
|
+
"title": "canopy repo",
|
|
262
|
+
"summary": "Create, clone and set up the project's git repository on Canopy."
|
|
263
|
+
},
|
|
254
264
|
{
|
|
255
265
|
"slug": "cli-egress",
|
|
256
266
|
"title": "canopy egress",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as version } from "./package-
|
|
1
|
+
import { t as version } from "./package-CQIPmdcO.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-C_jSc1Kz.mjs";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as version } from "./package-
|
|
1
|
+
import { t as version } from "./package-CQIPmdcO.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-C4rMhE7i.mjs";
|
|
4
4
|
import { r as reportErrors, t as formatDateTime } from "./ui-C_jSc1Kz.mjs";
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { t as version } from "./package-
|
|
2
|
+
import { t as version } from "./package-CQIPmdcO.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";
|
|
@@ -62,13 +62,13 @@ const main = defineCommand({
|
|
|
62
62
|
description: "canopy — create and deploy vibecoded projects"
|
|
63
63
|
},
|
|
64
64
|
subCommands: {
|
|
65
|
-
login: () => import("./login-
|
|
66
|
-
whoami: () => import("./login-
|
|
67
|
-
logout: () => import("./login-
|
|
68
|
-
create: () => import("./projects-
|
|
69
|
-
init: () => import("./projects-
|
|
70
|
-
projects: () => import("./projects-
|
|
71
|
-
delete: () => import("./projects-
|
|
65
|
+
login: () => import("./login-D0J5s_Ex.mjs").then((m) => m.loginCommand),
|
|
66
|
+
whoami: () => import("./login-D0J5s_Ex.mjs").then((m) => m.whoamiCommand),
|
|
67
|
+
logout: () => import("./login-D0J5s_Ex.mjs").then((m) => m.logoutCommand),
|
|
68
|
+
create: () => import("./projects-DQAhCZm8.mjs").then((m) => m.createCommand),
|
|
69
|
+
init: () => import("./projects-DQAhCZm8.mjs").then((m) => m.initCommand),
|
|
70
|
+
projects: () => import("./projects-DQAhCZm8.mjs").then((m) => m.projectsCommand),
|
|
71
|
+
delete: () => import("./projects-DQAhCZm8.mjs").then((m) => m.deleteCommand),
|
|
72
72
|
dev: () => import("./dev-MoHHjQh1.mjs").then((m) => m.devCommand),
|
|
73
73
|
deploy: () => import("./deploy-Ka2lHkyy.mjs").then((m) => m.deployCommand),
|
|
74
74
|
promote: () => import("./deploy-Ka2lHkyy.mjs").then((m) => m.promoteCommand),
|
|
@@ -79,19 +79,20 @@ const main = defineCommand({
|
|
|
79
79
|
egress: () => import("./egress-If_Gsx_M.mjs").then((m) => m.egressCommand),
|
|
80
80
|
secrets: () => import("./secrets-BSEjZnxg.mjs").then((m) => m.secretsCommand),
|
|
81
81
|
schedule: () => import("./schedule-C5lMf3ny.mjs").then((m) => m.scheduleCommand),
|
|
82
|
-
feedback: () => import("./feedback-
|
|
82
|
+
feedback: () => import("./feedback-5oPSxAz0.mjs").then((m) => m.feedbackCommand),
|
|
83
83
|
db: () => import("./db-BB6O6085.mjs").then((m) => m.dbCommand),
|
|
84
84
|
usage: () => import("./usage-CUYrWxv0.mjs").then((m) => m.usageCommand),
|
|
85
85
|
ai: () => import("./ai-DHLnyqfq.mjs").then((m) => m.aiCommand),
|
|
86
86
|
open: () => import("./open-DcaQKi1v.mjs").then((m) => m.openCommand),
|
|
87
|
-
docs: () => import("./docs-
|
|
88
|
-
skill: () => import("./skill-
|
|
89
|
-
upgrade: () => import("./upgrade-
|
|
87
|
+
docs: () => import("./docs-BP5jmD10.mjs").then((m) => m.docsCommand),
|
|
88
|
+
skill: () => import("./skill-Dg7dQF0k.mjs").then((m) => m.skillCommand),
|
|
89
|
+
upgrade: () => import("./upgrade-huJDjhrV.mjs").then((m) => m.upgradeCommand),
|
|
90
90
|
logs: () => import("./logs-DQQOawOG.mjs").then((m) => m.logsCommand),
|
|
91
|
-
mcp: () => import("./mcp-
|
|
91
|
+
mcp: () => import("./mcp-DXfTYVN_.mjs").then((m) => m.mcpCommand),
|
|
92
|
+
repo: () => import("./repo-1RQ1vBMv.mjs").then((m) => m.repoCommand)
|
|
92
93
|
}
|
|
93
94
|
});
|
|
94
|
-
const quiet = process.argv[2] === "mcp" && process.argv[3] === "serve";
|
|
95
|
+
const quiet = process.argv[2] === "mcp" && process.argv[3] === "serve" || process.argv[2] === "repo" && process.argv[3] === "credential";
|
|
95
96
|
if (process.argv[2] !== "upgrade" && !quiet) notifyOfUpdate(version);
|
|
96
97
|
if (!quiet && process.argv[2] && process.argv[2] !== "--help" && process.argv[2] !== "-h") console.error(siteHeaderLine(version));
|
|
97
98
|
await runMain(main);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { _ as writeConfigFile, f as clearAllProfileTokens, g as upsertProfileAuth, h as setActiveProfile, m as readConfigFile, n as siteConfig, p as listBakedProfiles, v as getBakedProfile, y as isProfileId } from "./site-DM2J10CB.mjs";
|
|
2
2
|
import { n as openBrowser, r as reportErrors } from "./ui-C_jSc1Kz.mjs";
|
|
3
|
+
import { n as clearRepoTokenCache } from "./repo-credential-CNDRM-tG.mjs";
|
|
3
4
|
import { defineCommand } from "citty";
|
|
4
5
|
import { hostname } from "node:os";
|
|
5
6
|
import * as p from "@clack/prompts";
|
|
@@ -98,8 +99,9 @@ const logoutCommand = defineCommand({
|
|
|
98
99
|
if (!result.ok) failed.push(`${id} (${result.error})`);
|
|
99
100
|
}
|
|
100
101
|
clearAllProfileTokens();
|
|
102
|
+
clearRepoTokenCache();
|
|
101
103
|
if (args.local) {
|
|
102
|
-
console.log("Logged out locally — tokens removed from ~/.canopy
|
|
104
|
+
console.log("Logged out locally — tokens removed from ~/.canopy but still valid on the platform until they expire (git repository tokens within the hour). Revoke them from the dashboard's Account page.");
|
|
103
105
|
return;
|
|
104
106
|
}
|
|
105
107
|
if (failed.length === 0) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as version } from "./package-
|
|
1
|
+
import { t as version } from "./package-CQIPmdcO.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-C4rMhE7i.mjs";
|
|
4
4
|
import { t as liveDevServer } from "./dev-server-file-Dk9pk4AR.mjs";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { n as siteConfig, s as projectUrl, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
|
|
2
2
|
import { i as apiRequest } from "./api-C4rMhE7i.mjs";
|
|
3
3
|
import { a as unwrap, i as requireTty, r as reportErrors } from "./ui-C_jSc1Kz.mjs";
|
|
4
|
+
import { r as offerRepoOnCreate } from "./repo-Bb9ntNa4.mjs";
|
|
4
5
|
import { defineCommand } from "citty";
|
|
5
6
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
6
7
|
import { join, relative, resolve, sep } from "node:path";
|
|
@@ -105,6 +106,11 @@ const createCommand = defineCommand({
|
|
|
105
106
|
type: "boolean",
|
|
106
107
|
description: "Scaffold ./<slug>/ from the starter template",
|
|
107
108
|
negativeDescription: "Only register — no files written"
|
|
109
|
+
},
|
|
110
|
+
repo: {
|
|
111
|
+
type: "boolean",
|
|
112
|
+
description: "Also create a git repository for the project on Canopy",
|
|
113
|
+
negativeDescription: "Don't create a git repository (no question asked)"
|
|
108
114
|
}
|
|
109
115
|
},
|
|
110
116
|
run: reportErrors("create", async ({ args }) => {
|
|
@@ -166,14 +172,20 @@ const createCommand = defineCommand({
|
|
|
166
172
|
...tryReadCanopyJson(targetDir),
|
|
167
173
|
slug
|
|
168
174
|
});
|
|
175
|
+
const remote = await offerRepoOnCreate(slug, targetDir, args.repo);
|
|
169
176
|
const steps = [
|
|
170
177
|
`cd ${slug}`,
|
|
171
178
|
...template.needsInstall ? ["npm install"] : [],
|
|
179
|
+
...remote ? [`git add -A && git commit -m "Initial commit" && git push -u ${remote} main`] : [],
|
|
172
180
|
"canopy dev (or deploy right away: canopy deploy)"
|
|
173
181
|
];
|
|
174
182
|
p.note(steps.join("\n"), `Scaffolded ./${slug}/ (${template.id}) — next`);
|
|
175
183
|
p.outro(`Live URL (after first deploy): ${projectUrl(project.slug)}`);
|
|
176
|
-
} else
|
|
184
|
+
} else {
|
|
185
|
+
const remote = await offerRepoOnCreate(slug, process.cwd(), args.repo);
|
|
186
|
+
if (remote) p.log.info(`This folder pushes to the project's git repository as "${remote}".`);
|
|
187
|
+
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.");
|
|
188
|
+
}
|
|
177
189
|
})
|
|
178
190
|
});
|
|
179
191
|
/** Empty enough to scaffold into: missing, or holding only dotfiles (.git, .agents…). */
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import { a as projectDirArgs, c as resolveProject, n as siteConfig, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
|
|
2
|
+
import { i as apiRequest, o as withContext } from "./api-C4rMhE7i.mjs";
|
|
3
|
+
import { a as unwrap, i as requireTty, r as reportErrors, t as formatDateTime } from "./ui-C_jSc1Kz.mjs";
|
|
4
|
+
import { a as parseCredentialInput, i as helperCommand, o as runCredentialHelper, r as fileTokenCache, t as RepoCredentialError } from "./repo-credential-CNDRM-tG.mjs";
|
|
5
|
+
import { defineCommand } from "citty";
|
|
6
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
7
|
+
import { delimiter, join, resolve } from "node:path";
|
|
8
|
+
import { execFile, spawn } from "node:child_process";
|
|
9
|
+
import * as p from "@clack/prompts";
|
|
10
|
+
//#region src/git.ts
|
|
11
|
+
/**
|
|
12
|
+
* The little git the CLI needs for Canopy git repositories (OpenSpec change
|
|
13
|
+
* project-git-repos): find git, run it, and read or write a working copy's
|
|
14
|
+
* local configuration. Only `canopy repo` (and `canopy create --repo`) use
|
|
15
|
+
* it; nothing else in the CLI requires git to be installed.
|
|
16
|
+
*/
|
|
17
|
+
/** Run git and capture its output. Never throws on a non-zero exit. */
|
|
18
|
+
function git(args, opts = {}) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
execFile("git", args, {
|
|
21
|
+
cwd: opts.cwd,
|
|
22
|
+
maxBuffer: 16 * 1024 * 1024
|
|
23
|
+
}, (err, stdout, stderr) => {
|
|
24
|
+
if (err && err.code === "ENOENT") {
|
|
25
|
+
reject(new GitNotFound());
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
resolve({
|
|
29
|
+
code: err ? Number(err.code ?? 1) || 1 : 0,
|
|
30
|
+
stdout: String(stdout),
|
|
31
|
+
stderr: String(stderr)
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/** Run git with its output on the user's terminal (clone progress and the like). */
|
|
37
|
+
function gitInteractive(args, opts = {}) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const child = spawn("git", args, {
|
|
40
|
+
cwd: opts.cwd,
|
|
41
|
+
stdio: "inherit"
|
|
42
|
+
});
|
|
43
|
+
child.on("error", (err) => reject(err.code === "ENOENT" ? new GitNotFound() : err));
|
|
44
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
var GitNotFound = class extends Error {
|
|
48
|
+
constructor() {
|
|
49
|
+
super("git is not installed (or not on your PATH). Install it from https://git-scm.com/downloads — on a Mac, `xcode-select --install` — and try again.");
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/** Throws GitNotFound when git is missing; returns the version string otherwise. */
|
|
53
|
+
async function requireGit() {
|
|
54
|
+
const { stdout } = await git(["--version"]);
|
|
55
|
+
return stdout.trim();
|
|
56
|
+
}
|
|
57
|
+
/** True when `dir` is inside a git working copy. */
|
|
58
|
+
async function isWorkingCopy(dir) {
|
|
59
|
+
const { code, stdout } = await git(["rev-parse", "--is-inside-work-tree"], { cwd: dir });
|
|
60
|
+
return code === 0 && stdout.trim() === "true";
|
|
61
|
+
}
|
|
62
|
+
/** The working copy's remotes, name → fetch URL. */
|
|
63
|
+
async function remotes(dir) {
|
|
64
|
+
const { stdout } = await git(["remote", "-v"], { cwd: dir });
|
|
65
|
+
const out = /* @__PURE__ */ new Map();
|
|
66
|
+
for (const line of stdout.split("\n")) {
|
|
67
|
+
const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/.exec(line.trim());
|
|
68
|
+
if (match) out.set(match[1], match[2]);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/** Replace every value of a key in the local configuration with `values`, in order. */
|
|
73
|
+
async function setLocalConfig(dir, key, values) {
|
|
74
|
+
const unset = await git([
|
|
75
|
+
"config",
|
|
76
|
+
"--local",
|
|
77
|
+
"--unset-all",
|
|
78
|
+
key
|
|
79
|
+
], { cwd: dir });
|
|
80
|
+
if (unset.code !== 0 && unset.code !== 5) throw new Error(`git config --unset-all ${key} failed: ${unset.stderr.trim()}`);
|
|
81
|
+
for (const value of values) {
|
|
82
|
+
const add = await git([
|
|
83
|
+
"config",
|
|
84
|
+
"--local",
|
|
85
|
+
"--add",
|
|
86
|
+
key,
|
|
87
|
+
value
|
|
88
|
+
], { cwd: dir });
|
|
89
|
+
if (add.code !== 0) throw new Error(`git config --add ${key} failed: ${add.stderr.trim()}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/repo.ts
|
|
94
|
+
/**
|
|
95
|
+
* canopy repo — the project's git repository (OpenSpec change
|
|
96
|
+
* project-git-repos).
|
|
97
|
+
*
|
|
98
|
+
* A project has at most one repository: a Canopy repository, which Canopy
|
|
99
|
+
* keeps for it, or one it links. For a Canopy repository:
|
|
100
|
+
*
|
|
101
|
+
* - `create` makes it, then sets up the current folder when it is the project;
|
|
102
|
+
* - `clone` clones it into a new folder;
|
|
103
|
+
* - `connect` sets up an existing folder;
|
|
104
|
+
* - `show` says which repository the project has (`--json` for agents);
|
|
105
|
+
* - `delete` removes it and its history (owner only).
|
|
106
|
+
*
|
|
107
|
+
* Setting up a folder points a remote at the repository and registers
|
|
108
|
+
* `canopy repo credential` as git's credential helper for that one
|
|
109
|
+
* repository. No token is ever written
|
|
110
|
+
* into the folder or its git configuration; git asks the helper on each fetch
|
|
111
|
+
* and push, and it hands over a short-lived token for the CLI sign-in.
|
|
112
|
+
*/
|
|
113
|
+
/** Plain words for the API's repository errors. */
|
|
114
|
+
const REPO_FIXES = {
|
|
115
|
+
repos_not_configured: "Git repositories are not available on this site yet. Use your own GitHub repository instead.",
|
|
116
|
+
repo_exists: "This project already has a repository — `canopy repo show` says which.",
|
|
117
|
+
repo_already_linked: "This project is linked to its own GitHub repository; a project has one repository.",
|
|
118
|
+
repo_name_taken: "A leftover repository with this name exists on the git host. Ask a platform admin to remove it, then try again.",
|
|
119
|
+
owner_only: "Only the project owner can delete its repository.",
|
|
120
|
+
no_repo: "This project has no Canopy repository — create one with `canopy repo create`."
|
|
121
|
+
};
|
|
122
|
+
const fetchRepo = (slug) => withContext(apiRequest("GET", `/projects/${slug}/repo`), {
|
|
123
|
+
action: `read ${slug}'s repository`,
|
|
124
|
+
fixes: REPO_FIXES
|
|
125
|
+
});
|
|
126
|
+
/**
|
|
127
|
+
* How git should start this CLI for the credential helper: the `canopy` (or
|
|
128
|
+
* `canopy-dev`) on PATH when it is this very install, so upgrades keep
|
|
129
|
+
* working; otherwise this node binary and script, by absolute path.
|
|
130
|
+
*/
|
|
131
|
+
function cliInvocation() {
|
|
132
|
+
const script = realpathSync(process.argv[1]);
|
|
133
|
+
for (const dir of (process.env["PATH"] ?? "").split(delimiter)) for (const name of ["canopy", "canopy-dev"]) {
|
|
134
|
+
const candidate = join(dir, name);
|
|
135
|
+
try {
|
|
136
|
+
if (existsSync(candidate) && realpathSync(candidate) === script) return [candidate];
|
|
137
|
+
} catch {}
|
|
138
|
+
}
|
|
139
|
+
return [process.execPath, script];
|
|
140
|
+
}
|
|
141
|
+
/** The git config lines that route this repository's credentials to the helper. */
|
|
142
|
+
function credentialConfig(gitUrl, profile, slug) {
|
|
143
|
+
const section = `credential.${gitUrl}`;
|
|
144
|
+
return [[`${section}.helper`, ["", helperCommand(cliInvocation(), profile, slug, gitUrl)]], [`${section}.useHttpPath`, ["true"]]];
|
|
145
|
+
}
|
|
146
|
+
function currentProfile() {
|
|
147
|
+
const profile = siteConfig().profileId;
|
|
148
|
+
if (!profile) throw new Error("Git repositories need a named site — pass --profile main, staging or gcp-pilot.");
|
|
149
|
+
return profile;
|
|
150
|
+
}
|
|
151
|
+
function requireCanopyRepo(slug, repo) {
|
|
152
|
+
if (repo.kind === "canopy") return repo;
|
|
153
|
+
if (repo.kind === "linked") throw new Error(`${slug} is linked to ${repo.linked_repository}; use that repository with your own git credentials.`);
|
|
154
|
+
throw new Error(`${slug} has no repository yet — create one with \`canopy repo create\`.`);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Set up `dir` as a working copy of the project's Canopy repository: a git
|
|
158
|
+
* repository if it isn't one, the remote (`origin`, or `canopy` when origin
|
|
159
|
+
* points elsewhere) and the credential helper. Returns the remote's name.
|
|
160
|
+
*/
|
|
161
|
+
async function connectFolder(dir, slug, repo) {
|
|
162
|
+
await requireGit();
|
|
163
|
+
if (!await isWorkingCopy(dir)) {
|
|
164
|
+
const init = await git([
|
|
165
|
+
"init",
|
|
166
|
+
"-q",
|
|
167
|
+
"-b",
|
|
168
|
+
"main"
|
|
169
|
+
], { cwd: dir });
|
|
170
|
+
if (init.code !== 0) throw new Error(`git init failed: ${init.stderr.trim()}`);
|
|
171
|
+
}
|
|
172
|
+
const existing = await remotes(dir);
|
|
173
|
+
let name = "origin";
|
|
174
|
+
const already = [...existing].find(([, url]) => url === repo.remote);
|
|
175
|
+
if (already) name = already[0];
|
|
176
|
+
else {
|
|
177
|
+
if (existing.has("origin")) name = "canopy";
|
|
178
|
+
if (existing.has(name)) throw new Error(`This folder already has a remote named "${name}" pointing elsewhere (${existing.get(name)}). Remove or rename it, then run \`canopy repo connect\` again.`);
|
|
179
|
+
const add = await git([
|
|
180
|
+
"remote",
|
|
181
|
+
"add",
|
|
182
|
+
name,
|
|
183
|
+
repo.remote
|
|
184
|
+
], { cwd: dir });
|
|
185
|
+
if (add.code !== 0) throw new Error(`git remote add failed: ${add.stderr.trim()}`);
|
|
186
|
+
}
|
|
187
|
+
for (const [key, values] of credentialConfig(repo.remote, currentProfile(), slug)) await setLocalConfig(dir, key, values);
|
|
188
|
+
return name;
|
|
189
|
+
}
|
|
190
|
+
const showCommand = defineCommand({
|
|
191
|
+
meta: {
|
|
192
|
+
name: "show",
|
|
193
|
+
description: "Show the project's git repository"
|
|
194
|
+
},
|
|
195
|
+
args: {
|
|
196
|
+
...projectDirArgs,
|
|
197
|
+
json: {
|
|
198
|
+
type: "boolean",
|
|
199
|
+
default: false,
|
|
200
|
+
description: "Print JSON (for scripts and agents)"
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
run: reportErrors("repo show", async ({ args }) => {
|
|
204
|
+
const { slug } = resolveProject(args);
|
|
205
|
+
const repo = await fetchRepo(slug);
|
|
206
|
+
if (args.json) {
|
|
207
|
+
console.log(JSON.stringify(repo, null, 2));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (repo.kind === "none") console.log(`${slug} has no git repository. Create one with \`canopy repo create\`.`);
|
|
211
|
+
else if (repo.kind === "linked") console.log(`${slug} is linked to ${repo.linked_repository}.`);
|
|
212
|
+
else {
|
|
213
|
+
console.log(`${slug} has a Canopy git repository.`);
|
|
214
|
+
console.log(` remote ${repo.remote}`);
|
|
215
|
+
console.log(` created ${repo.created_at ? formatDateTime(repo.created_at) : "—"}`);
|
|
216
|
+
console.log(` last push ${repo.last_push_at ? formatDateTime(repo.last_push_at) : "never"}`);
|
|
217
|
+
console.log(`\nClone it with \`canopy repo clone ${slug}\`.`);
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
});
|
|
221
|
+
const createCommand = defineCommand({
|
|
222
|
+
meta: {
|
|
223
|
+
name: "create",
|
|
224
|
+
description: "Create a Canopy git repository for the project"
|
|
225
|
+
},
|
|
226
|
+
args: { ...projectDirArgs },
|
|
227
|
+
run: reportErrors("repo create", async ({ args }) => {
|
|
228
|
+
const { dir, slug } = resolveProject(args);
|
|
229
|
+
const repo = await withContext(apiRequest("POST", `/projects/${slug}/repo`), {
|
|
230
|
+
action: `create a git repository for ${slug}`,
|
|
231
|
+
fixes: REPO_FIXES
|
|
232
|
+
});
|
|
233
|
+
p.log.success(`Created a git repository for ${slug}.`);
|
|
234
|
+
if (tryReadCanopyJson(dir)?.slug === slug) {
|
|
235
|
+
const remote = await connectFolder(dir, slug, repo);
|
|
236
|
+
p.log.info(`This folder pushes to it as "${remote}". Commit and push as usual:`);
|
|
237
|
+
console.log(` git add -A && git commit -m "Initial commit" && git push -u ${remote} main`);
|
|
238
|
+
} else p.log.info(`Clone it with \`canopy repo clone ${slug}\`.`);
|
|
239
|
+
})
|
|
240
|
+
});
|
|
241
|
+
const connectCommand = defineCommand({
|
|
242
|
+
meta: {
|
|
243
|
+
name: "connect",
|
|
244
|
+
description: "Set up a folder to push to the project's Canopy git repository"
|
|
245
|
+
},
|
|
246
|
+
args: { ...projectDirArgs },
|
|
247
|
+
run: reportErrors("repo connect", async ({ args }) => {
|
|
248
|
+
const { dir, slug } = resolveProject(args);
|
|
249
|
+
const remote = await connectFolder(dir, slug, requireCanopyRepo(slug, await fetchRepo(slug)));
|
|
250
|
+
p.log.success(`This folder now pushes to ${slug}'s repository as "${remote}".`);
|
|
251
|
+
})
|
|
252
|
+
});
|
|
253
|
+
const cloneCommand = defineCommand({
|
|
254
|
+
meta: {
|
|
255
|
+
name: "clone",
|
|
256
|
+
description: "Clone a project's Canopy git repository"
|
|
257
|
+
},
|
|
258
|
+
args: {
|
|
259
|
+
slug: {
|
|
260
|
+
type: "positional",
|
|
261
|
+
description: "Project slug",
|
|
262
|
+
required: true
|
|
263
|
+
},
|
|
264
|
+
target: {
|
|
265
|
+
type: "positional",
|
|
266
|
+
description: "Folder to clone into (defaults to the slug)",
|
|
267
|
+
required: false
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
run: reportErrors("repo clone", async ({ args }) => {
|
|
271
|
+
const slug = args.slug;
|
|
272
|
+
const repo = requireCanopyRepo(slug, await fetchRepo(slug));
|
|
273
|
+
await requireGit();
|
|
274
|
+
const target = resolve(args.target ?? slug);
|
|
275
|
+
const code = await gitInteractive([
|
|
276
|
+
"clone",
|
|
277
|
+
...credentialConfig(repo.remote, currentProfile(), slug).flatMap(([key, values]) => values.flatMap((value) => ["-c", `${key}=${value}`])),
|
|
278
|
+
repo.remote,
|
|
279
|
+
target
|
|
280
|
+
]);
|
|
281
|
+
if (code !== 0) throw new Error(`git clone failed (exit ${code}).`);
|
|
282
|
+
p.log.success(`Cloned ${slug} into ${target}.`);
|
|
283
|
+
})
|
|
284
|
+
});
|
|
285
|
+
const deleteCommand = defineCommand({
|
|
286
|
+
meta: {
|
|
287
|
+
name: "delete",
|
|
288
|
+
description: "Delete the project's Canopy git repository and its history"
|
|
289
|
+
},
|
|
290
|
+
args: {
|
|
291
|
+
...projectDirArgs,
|
|
292
|
+
yes: {
|
|
293
|
+
type: "boolean",
|
|
294
|
+
default: false,
|
|
295
|
+
description: "Skip the type-the-slug confirmation"
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
run: reportErrors("repo delete", async ({ args }) => {
|
|
299
|
+
const { slug } = resolveProject(args);
|
|
300
|
+
if (!args.yes) {
|
|
301
|
+
requireTty("pass --yes to delete without confirmation.");
|
|
302
|
+
if (unwrap(await p.text({ message: `Delete ${slug}'s git repository and all its history? Type the slug to confirm.` })).trim() !== slug) {
|
|
303
|
+
p.cancel("Aborted.");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
await withContext(apiRequest("DELETE", `/projects/${slug}/repo`), {
|
|
308
|
+
action: `delete ${slug}'s git repository`,
|
|
309
|
+
fixes: REPO_FIXES
|
|
310
|
+
});
|
|
311
|
+
p.log.success(`Deleted ${slug}'s git repository.`);
|
|
312
|
+
})
|
|
313
|
+
});
|
|
314
|
+
/** Exchange this site's CLI sign-in for a repository token (never a deploy token). */
|
|
315
|
+
async function mintRepoToken(profile, slug) {
|
|
316
|
+
const config = siteConfig();
|
|
317
|
+
const login = `run \`canopy login --profile ${profile}\``;
|
|
318
|
+
if (!config.token) throw new RepoCredentialError(`not signed in to ${profile} — ${login}.`);
|
|
319
|
+
const path = `/projects/${encodeURIComponent(slug)}/repo/token`;
|
|
320
|
+
const res = await fetch(`${config.apiUrl}/control/v1${path}`, {
|
|
321
|
+
method: "POST",
|
|
322
|
+
headers: { authorization: `Bearer ${config.token}` }
|
|
323
|
+
}).catch(() => null);
|
|
324
|
+
if (!res) throw new RepoCredentialError(`could not reach Canopy (${config.apiUrl}).`);
|
|
325
|
+
if (res.status === 201) return await res.json();
|
|
326
|
+
const body = await res.json().catch(() => ({}));
|
|
327
|
+
if (res.status === 401) throw new RepoCredentialError(`your ${profile} sign-in has expired — ${login}.`);
|
|
328
|
+
if (res.status === 404) throw new RepoCredentialError(`you don't have access to ${slug}'s repository (or it no longer exists).`);
|
|
329
|
+
throw new RepoCredentialError(body.detail ?? body.error ?? `Canopy answered HTTP ${res.status}.`);
|
|
330
|
+
}
|
|
331
|
+
const credentialCommand = defineCommand({
|
|
332
|
+
meta: {
|
|
333
|
+
name: "credential",
|
|
334
|
+
description: "git credential helper (set up by clone/connect)",
|
|
335
|
+
hidden: true
|
|
336
|
+
},
|
|
337
|
+
args: {
|
|
338
|
+
action: {
|
|
339
|
+
type: "positional",
|
|
340
|
+
description: "get | store | erase",
|
|
341
|
+
required: true
|
|
342
|
+
},
|
|
343
|
+
project: {
|
|
344
|
+
type: "string",
|
|
345
|
+
description: "Project slug",
|
|
346
|
+
required: true
|
|
347
|
+
},
|
|
348
|
+
url: {
|
|
349
|
+
type: "string",
|
|
350
|
+
description: "Repository URL git talks to",
|
|
351
|
+
required: true
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
run: async ({ args }) => {
|
|
355
|
+
const profile = siteConfig().profileId;
|
|
356
|
+
const input = parseCredentialInput(await readStdin());
|
|
357
|
+
const out = profile ? await runCredentialHelper(args.action, input, {
|
|
358
|
+
cacheKey: `${profile}/${args.project}`,
|
|
359
|
+
repoUrl: args.url,
|
|
360
|
+
cache: fileTokenCache(),
|
|
361
|
+
mint: () => mintRepoToken(profile, args.project)
|
|
362
|
+
}) : {
|
|
363
|
+
stdout: "quit=1\n",
|
|
364
|
+
stderr: "canopy: this repository's site is unknown — run `canopy repo connect` again.\n"
|
|
365
|
+
};
|
|
366
|
+
process.stderr.write(out.stderr);
|
|
367
|
+
process.stdout.write(out.stdout);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
async function readStdin() {
|
|
371
|
+
const chunks = [];
|
|
372
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
373
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
374
|
+
}
|
|
375
|
+
const repoCommand = defineCommand({
|
|
376
|
+
meta: {
|
|
377
|
+
name: "repo",
|
|
378
|
+
description: "The project's git repository (clone, connect, create)"
|
|
379
|
+
},
|
|
380
|
+
subCommands: {
|
|
381
|
+
show: showCommand,
|
|
382
|
+
clone: cloneCommand,
|
|
383
|
+
connect: connectCommand,
|
|
384
|
+
create: createCommand,
|
|
385
|
+
delete: deleteCommand,
|
|
386
|
+
credential: credentialCommand
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
/** Whether this site offers Canopy git repositories (the `repos` capability). */
|
|
390
|
+
async function reposAvailable() {
|
|
391
|
+
const platform = await apiRequest("GET", "/platform").catch(() => null);
|
|
392
|
+
return Boolean(platform?.capabilities?.repos);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* The repository step of `canopy create` (design D-H). `--repo` creates one,
|
|
396
|
+
* `--no-repo` doesn't; otherwise an interactive run is asked (default no) when
|
|
397
|
+
* the site offers repositories and the folder doesn't already push to GitHub.
|
|
398
|
+
* A repository that can't be created never fails the project creation.
|
|
399
|
+
* Returns the remote name when the folder was set up.
|
|
400
|
+
*/
|
|
401
|
+
async function offerRepoOnCreate(slug, dir, flag) {
|
|
402
|
+
if (flag === false) return null;
|
|
403
|
+
if (flag === void 0) {
|
|
404
|
+
if (!process.stdin.isTTY || !await reposAvailable()) return null;
|
|
405
|
+
const origin = await isWorkingCopy(dir).catch(() => false) ? (await remotes(dir)).get("origin") : void 0;
|
|
406
|
+
if (origin && /github\.com[:/]/.test(origin)) return null;
|
|
407
|
+
if (!unwrap(await p.confirm({
|
|
408
|
+
message: "Create a git repository for this project on Canopy?",
|
|
409
|
+
initialValue: false
|
|
410
|
+
}))) return null;
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
return await connectFolder(dir, slug, await withContext(apiRequest("POST", `/projects/${slug}/repo`), {
|
|
414
|
+
action: `create a git repository for ${slug}`,
|
|
415
|
+
fixes: REPO_FIXES
|
|
416
|
+
}));
|
|
417
|
+
} catch (err) {
|
|
418
|
+
p.log.warn(`The project was created, but its git repository wasn't: ${err instanceof Error ? err.message : String(err)}\nTry again later with \`canopy repo create\`.`);
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
//#endregion
|
|
423
|
+
export { repoCommand as i, connectFolder as n, offerRepoOnCreate as r, cliInvocation as t };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
/** Why no token could be had — shown to the user, then git is told to quit. */
|
|
5
|
+
var RepoCredentialError = class extends Error {};
|
|
6
|
+
const REPO_TOKEN_CACHE_PATH = join(homedir(), ".canopy", "repo-tokens.json");
|
|
7
|
+
/** The on-disk cache: one JSON object, owner-only. */
|
|
8
|
+
function fileTokenCache(path = REPO_TOKEN_CACHE_PATH) {
|
|
9
|
+
const read = () => {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
12
|
+
} catch {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const write = (all) => {
|
|
17
|
+
mkdirSync(dirname(path), {
|
|
18
|
+
recursive: true,
|
|
19
|
+
mode: 448
|
|
20
|
+
});
|
|
21
|
+
writeFileSync(path, `${JSON.stringify(all, null, 2)}\n`, { mode: 384 });
|
|
22
|
+
chmodSync(path, 384);
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
get: (key) => read()[key],
|
|
26
|
+
set: (key, value) => write({
|
|
27
|
+
...read(),
|
|
28
|
+
[key]: value
|
|
29
|
+
}),
|
|
30
|
+
delete: (key) => {
|
|
31
|
+
const all = read();
|
|
32
|
+
if (!(key in all)) return;
|
|
33
|
+
delete all[key];
|
|
34
|
+
write(all);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** `canopy logout` forgets every cached repository token. */
|
|
39
|
+
function clearRepoTokenCache(path = REPO_TOKEN_CACHE_PATH) {
|
|
40
|
+
rmSync(path, { force: true });
|
|
41
|
+
}
|
|
42
|
+
/** git's credential protocol: `key=value` lines, terminated by a blank line or EOF. */
|
|
43
|
+
function parseCredentialInput(text) {
|
|
44
|
+
const out = /* @__PURE__ */ new Map();
|
|
45
|
+
for (const line of text.split("\n")) {
|
|
46
|
+
if (line === "") break;
|
|
47
|
+
const eq = line.indexOf("=");
|
|
48
|
+
if (eq > 0) out.set(line.slice(0, eq), line.slice(eq + 1));
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
const normalize = (url) => url.replace(/\/+$/, "").replace(/\.git$/, "");
|
|
53
|
+
/** Whether git is asking about exactly the repository this helper was registered for. */
|
|
54
|
+
function requestMatches(input, repoUrl) {
|
|
55
|
+
const protocol = input.get("protocol");
|
|
56
|
+
const host = input.get("host");
|
|
57
|
+
const path = input.get("path");
|
|
58
|
+
if (protocol !== "https" || !host || !path) return false;
|
|
59
|
+
return normalize(`${protocol}://${host}/${path}`) === normalize(repoUrl);
|
|
60
|
+
}
|
|
61
|
+
/** Answer one git credential request. */
|
|
62
|
+
async function runCredentialHelper(action, input, opts) {
|
|
63
|
+
const now = opts.now ?? Date.now;
|
|
64
|
+
if (!requestMatches(input, opts.repoUrl)) return {
|
|
65
|
+
stdout: "",
|
|
66
|
+
stderr: ""
|
|
67
|
+
};
|
|
68
|
+
if (action === "erase") {
|
|
69
|
+
opts.cache.delete(opts.cacheKey);
|
|
70
|
+
return {
|
|
71
|
+
stdout: "",
|
|
72
|
+
stderr: ""
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (action !== "get") return {
|
|
76
|
+
stdout: "",
|
|
77
|
+
stderr: ""
|
|
78
|
+
};
|
|
79
|
+
let token = opts.cache.get(opts.cacheKey);
|
|
80
|
+
if (!token || Date.parse(token.expires_at) - now() <= 3e5) {
|
|
81
|
+
try {
|
|
82
|
+
token = await opts.mint();
|
|
83
|
+
} catch (err) {
|
|
84
|
+
opts.cache.delete(opts.cacheKey);
|
|
85
|
+
return {
|
|
86
|
+
stdout: "quit=1\n",
|
|
87
|
+
stderr: `canopy: ${err instanceof RepoCredentialError ? err.message : String(err)}\n`
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
opts.cache.set(opts.cacheKey, token);
|
|
91
|
+
}
|
|
92
|
+
const expiry = Math.floor(Date.parse(token.expires_at) / 1e3);
|
|
93
|
+
return {
|
|
94
|
+
stdout: `username=x-access-token\npassword=${token.token}\npassword_expiry_utc=${expiry}\n`,
|
|
95
|
+
stderr: ""
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The shell command git runs for the helper, as written into
|
|
100
|
+
* `credential.<url>.helper`. Absolute paths, so GUI git clients without the
|
|
101
|
+
* user's PATH still find it.
|
|
102
|
+
*/
|
|
103
|
+
function helperCommand(cli, profile, slug, repoUrl) {
|
|
104
|
+
const quote = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
|
|
105
|
+
return `!${[
|
|
106
|
+
...cli.map(quote),
|
|
107
|
+
"repo",
|
|
108
|
+
"credential",
|
|
109
|
+
"--profile",
|
|
110
|
+
quote(profile),
|
|
111
|
+
"--project",
|
|
112
|
+
quote(slug),
|
|
113
|
+
"--url",
|
|
114
|
+
quote(repoUrl)
|
|
115
|
+
].join(" ")}`;
|
|
116
|
+
}
|
|
117
|
+
//#endregion
|
|
118
|
+
export { parseCredentialInput as a, helperCommand as i, clearRepoTokenCache as n, runCredentialHelper as o, fileTokenCache as r, RepoCredentialError as t };
|
|
@@ -104,6 +104,11 @@ and never go stale.
|
|
|
104
104
|
- Work bug reports & feedback: `canopy feedback list | show | comment | status | report`
|
|
105
105
|
— see [Working feedback reports](#working-feedback-reports-issues) below
|
|
106
106
|
- CI credentials: `canopy token create | list | revoke`
|
|
107
|
+
- Git: `canopy repo show --json` (is there a repository, and its remote) ·
|
|
108
|
+
`canopy repo create` · `canopy repo clone <slug>` · `canopy repo connect`
|
|
109
|
+
(set up an existing folder). Then plain `git`: the CLI gives git
|
|
110
|
+
short-lived credentials itself, so never ask for, paste or store a git
|
|
111
|
+
token. Pushing doesn't deploy — `canopy deploy` does.
|
|
107
112
|
|
|
108
113
|
## Working feedback reports (issues)
|
|
109
114
|
|
|
@@ -183,7 +188,8 @@ Non-interactive: `canopy create "My App" --template spa`. **When unsure between
|
|
|
183
188
|
|
|
184
189
|
## Safety rules
|
|
185
190
|
|
|
186
|
-
- **Destructive commands** — `canopy db restore
|
|
191
|
+
- **Destructive commands** — `canopy db restore`, `canopy delete` and
|
|
192
|
+
`canopy repo delete` (the project's git history) wipe data.
|
|
187
193
|
Confirm intent with the user first, and take a `canopy db snapshot` before
|
|
188
194
|
restores.
|
|
189
195
|
- **Deploys are public.** `canopy deploy` makes a version live. Confirm before
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as version } from "./package-
|
|
1
|
+
import { t as version } from "./package-CQIPmdcO.mjs";
|
|
2
2
|
import { r as reportErrors } from "./ui-C_jSc1Kz.mjs";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import { cpSync, existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
@@ -35,6 +35,19 @@ the [Canopy dashboard](https://dashboard.thanarie.dev).
|
|
|
35
35
|
`canopy promote` ships the exact tested version. Never promote with failing
|
|
36
36
|
checks.
|
|
37
37
|
|
|
38
|
+
## Git
|
|
39
|
+
|
|
40
|
+
Code can live in a git repository Canopy keeps for the project (no GitHub
|
|
41
|
+
account needed). `canopy repo show --json` says whether there is one and gives
|
|
42
|
+
its remote; `canopy repo create` makes one, `canopy repo clone <slug>` or
|
|
43
|
+
`canopy repo connect` sets a folder up. After that it is plain `git` — the CLI
|
|
44
|
+
hands git short-lived credentials by itself:
|
|
45
|
+
|
|
46
|
+
- Never ask for, paste or store a git token or password.
|
|
47
|
+
- Pushing doesn't deploy — `canopy deploy` still does.
|
|
48
|
+
- The `.github/workflows` file here only runs on GitHub; on a Canopy
|
|
49
|
+
repository it is kept but never runs.
|
|
50
|
+
|
|
38
51
|
## Boundaries
|
|
39
52
|
|
|
40
53
|
- Do not add auth — the platform authenticates every request before it
|
|
@@ -34,6 +34,17 @@ the [Canopy dashboard](https://dashboard.thanarie.dev).
|
|
|
34
34
|
`canopy promote` ships the exact tested version. Never promote with failing
|
|
35
35
|
checks.
|
|
36
36
|
|
|
37
|
+
## Git
|
|
38
|
+
|
|
39
|
+
Code can live in a git repository Canopy keeps for the project (no GitHub
|
|
40
|
+
account needed). `canopy repo show --json` says whether there is one and gives
|
|
41
|
+
its remote; `canopy repo create` makes one, `canopy repo clone <slug>` or
|
|
42
|
+
`canopy repo connect` sets a folder up. After that it is plain `git` — the CLI
|
|
43
|
+
hands git short-lived credentials by itself:
|
|
44
|
+
|
|
45
|
+
- Never ask for, paste or store a git token or password.
|
|
46
|
+
- Pushing doesn't deploy — `canopy deploy` still does.
|
|
47
|
+
|
|
37
48
|
## Boundaries
|
|
38
49
|
|
|
39
50
|
- Do not add auth — the platform authenticates every request before it
|
|
@@ -51,6 +51,19 @@ the [Canopy dashboard](https://dashboard.thanarie.dev).
|
|
|
51
51
|
`canopy promote` ships the exact tested version. Never promote with failing
|
|
52
52
|
checks.
|
|
53
53
|
|
|
54
|
+
## Git
|
|
55
|
+
|
|
56
|
+
Code can live in a git repository Canopy keeps for the project (no GitHub
|
|
57
|
+
account needed). `canopy repo show --json` says whether there is one and gives
|
|
58
|
+
its remote; `canopy repo create` makes one, `canopy repo clone <slug>` or
|
|
59
|
+
`canopy repo connect` sets a folder up. After that it is plain `git` — the CLI
|
|
60
|
+
hands git short-lived credentials by itself:
|
|
61
|
+
|
|
62
|
+
- Never ask for, paste or store a git token or password.
|
|
63
|
+
- Pushing doesn't deploy — `canopy deploy` still does.
|
|
64
|
+
- The `.github/workflows` file here only runs on GitHub; on a Canopy
|
|
65
|
+
repository it is kept but never runs.
|
|
66
|
+
|
|
54
67
|
## Boundaries
|
|
55
68
|
|
|
56
69
|
- Do not add auth — the platform authenticates every request before it
|
|
@@ -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-
|
|
115
|
+
const { skillInstalled } = await import("./skill-Dg7dQF0k.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());
|