@mediamonks/canopy-cli 0.0.1
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/api-BEY4O4ap.mjs +36 -0
- package/dist/db-BBcgT1md.mjs +151 -0
- package/dist/deploy-BfhAlb91.mjs +152 -0
- package/dist/dev-BB7CwqoI.mjs +170 -0
- package/dist/dev-Bvy21srH.mjs +2 -0
- package/dist/index.mjs +55 -0
- package/dist/local-DhufX0eJ.mjs +563 -0
- package/dist/login-DrXKOEmr.mjs +146 -0
- package/dist/open-DFWvrLFc.mjs +23 -0
- package/dist/project-CrrpLEPs.mjs +54 -0
- package/dist/projects-f_KocDae.mjs +295 -0
- package/dist/ui-Cl5P_yQ6.mjs +72 -0
- package/package.json +36 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { a as readConfig } from "./ui-Cl5P_yQ6.mjs";
|
|
2
|
+
//#region src/api.ts
|
|
3
|
+
/**
|
|
4
|
+
* Authenticated requests to the Canopy control plane, using the CLI token
|
|
5
|
+
* from `canopy login` (~/.canopy/config.json).
|
|
6
|
+
*/
|
|
7
|
+
var ApiError = class extends Error {
|
|
8
|
+
status;
|
|
9
|
+
code;
|
|
10
|
+
constructor(status, code, detail) {
|
|
11
|
+
super(detail ? `${code}: ${detail}` : code);
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
async function apiRequest(method, path, body, rawContentType) {
|
|
17
|
+
const config = readConfig();
|
|
18
|
+
if (!config.token) throw new Error("Not logged in — run `canopy login` first.");
|
|
19
|
+
const isForm = body instanceof FormData;
|
|
20
|
+
const response = await fetch(`${config.apiUrl}/v1${path}`, {
|
|
21
|
+
method,
|
|
22
|
+
headers: {
|
|
23
|
+
authorization: `Bearer ${config.token}`,
|
|
24
|
+
...body === void 0 || isForm ? {} : { "content-type": rawContentType ?? "application/json" }
|
|
25
|
+
},
|
|
26
|
+
body: body === void 0 ? void 0 : isForm ? body : rawContentType !== void 0 ? body : JSON.stringify(body)
|
|
27
|
+
});
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
const payload = await response.json().catch(() => ({}));
|
|
30
|
+
if (response.status === 401) throw new Error("Session expired — run `canopy login` again.");
|
|
31
|
+
throw new ApiError(response.status, payload.error ?? `http_${response.status}`, payload.detail);
|
|
32
|
+
}
|
|
33
|
+
return await response.json();
|
|
34
|
+
}
|
|
35
|
+
//#endregion
|
|
36
|
+
export { apiRequest as t };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { i as unwrap, n as reportErrors, r as requireTty } from "./ui-Cl5P_yQ6.mjs";
|
|
2
|
+
import { t as apiRequest } from "./api-BEY4O4ap.mjs";
|
|
3
|
+
import { i as resolveProject, n as projectFlagArgs } from "./project-CrrpLEPs.mjs";
|
|
4
|
+
import { defineCommand } from "citty";
|
|
5
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import * as p from "@clack/prompts";
|
|
7
|
+
//#region src/db.ts
|
|
8
|
+
/**
|
|
9
|
+
* canopy db pull | snapshot | snapshots | restore — data safety from the
|
|
10
|
+
* terminal (docs/04-roadmap.md Phase 2 item 4, pulled forward).
|
|
11
|
+
*
|
|
12
|
+
* pull download a fresh SQL dump of the project database
|
|
13
|
+
* snapshot store a dump server-side (restorable from CLI or dashboard)
|
|
14
|
+
* snapshots list stored snapshots
|
|
15
|
+
* restore replace ALL project data from a snapshot or a local .sql file
|
|
16
|
+
*
|
|
17
|
+
* The project comes from canopy.json in the current directory, or
|
|
18
|
+
* --project <slug>.
|
|
19
|
+
*/
|
|
20
|
+
const formatSize = (bytes) => bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} kB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
21
|
+
const dbCommand = defineCommand({
|
|
22
|
+
meta: {
|
|
23
|
+
name: "db",
|
|
24
|
+
description: "Data tools: pull, snapshot, snapshots, restore"
|
|
25
|
+
},
|
|
26
|
+
subCommands: {
|
|
27
|
+
pull: defineCommand({
|
|
28
|
+
meta: {
|
|
29
|
+
name: "pull",
|
|
30
|
+
description: "Download a fresh SQL dump of the project database"
|
|
31
|
+
},
|
|
32
|
+
args: {
|
|
33
|
+
...projectFlagArgs,
|
|
34
|
+
out: {
|
|
35
|
+
type: "string",
|
|
36
|
+
description: "Output file (defaults to <slug>-<timestamp>.sql)"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
run: reportErrors("db pull", async ({ args }) => {
|
|
40
|
+
const { slug } = resolveProject(args);
|
|
41
|
+
const out = args.out ?? `${slug}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replaceAll(/[:T]/g, "-")}.sql`;
|
|
42
|
+
const spin = p.spinner();
|
|
43
|
+
spin.start(`Exporting ${slug}…`);
|
|
44
|
+
try {
|
|
45
|
+
const { url } = await apiRequest("POST", `/projects/${slug}/export`);
|
|
46
|
+
const download = await fetch(url);
|
|
47
|
+
if (!download.ok) throw new Error(`Download failed (HTTP ${download.status}).`);
|
|
48
|
+
const sql = Buffer.from(await download.arrayBuffer());
|
|
49
|
+
writeFileSync(out, sql);
|
|
50
|
+
spin.stop(`Wrote ${out} (${formatSize(sql.byteLength)}).`);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
spin.error("Export failed.");
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
}),
|
|
57
|
+
snapshot: defineCommand({
|
|
58
|
+
meta: {
|
|
59
|
+
name: "snapshot",
|
|
60
|
+
description: "Store a dump server-side (restorable anytime)"
|
|
61
|
+
},
|
|
62
|
+
args: projectFlagArgs,
|
|
63
|
+
run: reportErrors("db snapshot", async ({ args }) => {
|
|
64
|
+
const { slug } = resolveProject(args);
|
|
65
|
+
const spin = p.spinner();
|
|
66
|
+
spin.start(`Snapshotting ${slug}…`);
|
|
67
|
+
let created;
|
|
68
|
+
try {
|
|
69
|
+
({snapshot: created} = await apiRequest("POST", `/projects/${slug}/snapshots`));
|
|
70
|
+
} catch (err) {
|
|
71
|
+
spin.error("Snapshot failed.");
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
spin.stop(`Stored ${created.key} (${formatSize(created.size)}).`);
|
|
75
|
+
console.log(`Restore anytime with \`canopy db restore --snapshot ${created.key}\`.`);
|
|
76
|
+
})
|
|
77
|
+
}),
|
|
78
|
+
snapshots: defineCommand({
|
|
79
|
+
meta: {
|
|
80
|
+
name: "snapshots",
|
|
81
|
+
description: "List stored snapshots"
|
|
82
|
+
},
|
|
83
|
+
args: projectFlagArgs,
|
|
84
|
+
run: reportErrors("db snapshots", async ({ args }) => {
|
|
85
|
+
const { slug } = resolveProject(args);
|
|
86
|
+
const { snapshots } = await apiRequest("GET", `/projects/${slug}/snapshots`);
|
|
87
|
+
if (snapshots.length === 0) {
|
|
88
|
+
console.log(`No snapshots for ${slug} yet — run \`canopy db snapshot\`.`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
for (const snapshot of snapshots) console.log(`${snapshot.key} ${formatSize(snapshot.size).padStart(9)} ${snapshot.createdAt}`);
|
|
92
|
+
})
|
|
93
|
+
}),
|
|
94
|
+
restore: defineCommand({
|
|
95
|
+
meta: {
|
|
96
|
+
name: "restore",
|
|
97
|
+
description: "Replace ALL project data from a snapshot or a local .sql file"
|
|
98
|
+
},
|
|
99
|
+
args: {
|
|
100
|
+
...projectFlagArgs,
|
|
101
|
+
file: {
|
|
102
|
+
type: "positional",
|
|
103
|
+
description: "Local .sql dump to restore from",
|
|
104
|
+
required: false
|
|
105
|
+
},
|
|
106
|
+
snapshot: {
|
|
107
|
+
type: "string",
|
|
108
|
+
description: "Server-side snapshot key to restore from"
|
|
109
|
+
},
|
|
110
|
+
yes: {
|
|
111
|
+
type: "boolean",
|
|
112
|
+
default: false,
|
|
113
|
+
description: "Skip the type-the-slug confirmation"
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
run: reportErrors("db restore", async ({ args }) => {
|
|
117
|
+
const { slug } = resolveProject(args);
|
|
118
|
+
const { file } = args;
|
|
119
|
+
let snapshotKey = args.snapshot;
|
|
120
|
+
if (!snapshotKey && !file) {
|
|
121
|
+
requireTty("pass a local .sql dump or --snapshot <key>.");
|
|
122
|
+
const { snapshots } = await apiRequest("GET", `/projects/${slug}/snapshots`);
|
|
123
|
+
if (snapshots.length === 0) throw new Error(`No snapshots for ${slug} — run \`canopy db snapshot\` first, or pass a local .sql dump.`);
|
|
124
|
+
snapshotKey = unwrap(await p.select({
|
|
125
|
+
message: `Snapshot to restore into ${slug}`,
|
|
126
|
+
options: snapshots.map((snapshot) => ({
|
|
127
|
+
value: snapshot.key,
|
|
128
|
+
label: snapshot.key,
|
|
129
|
+
hint: `${formatSize(snapshot.size)} · ${snapshot.createdAt}`
|
|
130
|
+
}))
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
if (!args.yes) {
|
|
134
|
+
requireTty("pass --yes to restore without confirmation.");
|
|
135
|
+
if (unwrap(await p.text({ message: `Restore will REPLACE all current data in "${slug}" with ${snapshotKey ?? file}. Type the slug to confirm.` })).trim() !== slug) {
|
|
136
|
+
p.cancel("Aborted.");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (snapshotKey) await apiRequest("POST", `/projects/${slug}/restore`, { snapshot: snapshotKey });
|
|
141
|
+
else if (file) {
|
|
142
|
+
const sql = readFileSync(file, "utf8");
|
|
143
|
+
await apiRequest("POST", `/projects/${slug}/restore`, sql, "application/sql");
|
|
144
|
+
}
|
|
145
|
+
p.log.success(`Restored ${slug}.`);
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
//#endregion
|
|
151
|
+
export { dbCommand };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { i as unwrap, n as reportErrors } from "./ui-Cl5P_yQ6.mjs";
|
|
2
|
+
import { t as apiRequest } from "./api-BEY4O4ap.mjs";
|
|
3
|
+
import { a as tryReadCanopyJson, i as resolveProject, n as projectFlagArgs, r as projectUrl, t as projectDirArgs } from "./project-CrrpLEPs.mjs";
|
|
4
|
+
import { defineCommand } from "citty";
|
|
5
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
6
|
+
import * as p from "@clack/prompts";
|
|
7
|
+
import { join, relative, resolve } from "node:path";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
//#region src/deploy.ts
|
|
10
|
+
/**
|
|
11
|
+
* canopy deploy — build (optional), bundle the output directory as multipart
|
|
12
|
+
* form-data and POST it to the control plane; the deploy is live immediately
|
|
13
|
+
* (docs/04-roadmap.md Phase 1 item 3).
|
|
14
|
+
*
|
|
15
|
+
* Reads canopy.json in the project root:
|
|
16
|
+
* { "slug": "my-app", "dist": ".", "build": "npm run build" }
|
|
17
|
+
*/
|
|
18
|
+
const IGNORED = /* @__PURE__ */ new Set([
|
|
19
|
+
"canopy.json",
|
|
20
|
+
"CLAUDE.md",
|
|
21
|
+
"README.md",
|
|
22
|
+
".gitignore",
|
|
23
|
+
".DS_Store"
|
|
24
|
+
]);
|
|
25
|
+
const IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
26
|
+
".git",
|
|
27
|
+
"node_modules",
|
|
28
|
+
".claude"
|
|
29
|
+
]);
|
|
30
|
+
function runBuild(command, cwd) {
|
|
31
|
+
console.log(`Building: ${command}`);
|
|
32
|
+
return new Promise((resolvePromise, reject) => {
|
|
33
|
+
spawn(command, {
|
|
34
|
+
cwd,
|
|
35
|
+
shell: true,
|
|
36
|
+
stdio: "inherit"
|
|
37
|
+
}).on("close", (code) => {
|
|
38
|
+
if (code === 0) resolvePromise();
|
|
39
|
+
else reject(/* @__PURE__ */ new Error(`Build failed (exit ${code ?? "?"}).`));
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
function collectFiles(root, dir = root) {
|
|
44
|
+
const files = [];
|
|
45
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
46
|
+
const path = join(dir, entry.name);
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
if (!IGNORED_DIRS.has(entry.name) && !entry.name.startsWith(".")) files.push(...collectFiles(root, path));
|
|
49
|
+
} else if (!IGNORED.has(entry.name)) files.push(path);
|
|
50
|
+
}
|
|
51
|
+
return files;
|
|
52
|
+
}
|
|
53
|
+
const deployCommand = defineCommand({
|
|
54
|
+
meta: {
|
|
55
|
+
name: "deploy",
|
|
56
|
+
description: "Build and deploy the project"
|
|
57
|
+
},
|
|
58
|
+
args: {
|
|
59
|
+
...projectDirArgs,
|
|
60
|
+
build: {
|
|
61
|
+
type: "boolean",
|
|
62
|
+
default: true,
|
|
63
|
+
description: "Run the canopy.json build command first",
|
|
64
|
+
negativeDescription: "Skip the build, deploy the output directory as-is"
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
run: reportErrors("deploy", async ({ args }) => {
|
|
68
|
+
const { dir, slug } = resolveProject(args);
|
|
69
|
+
const config = tryReadCanopyJson(dir) ?? {};
|
|
70
|
+
if (config.build && args.build) await runBuild(config.build, dir);
|
|
71
|
+
const distDir = resolve(dir, config.dist ?? ".");
|
|
72
|
+
let paths;
|
|
73
|
+
try {
|
|
74
|
+
if (!statSync(distDir).isDirectory()) throw new Error("not a directory");
|
|
75
|
+
paths = collectFiles(distDir);
|
|
76
|
+
} catch {
|
|
77
|
+
throw new Error(`Output directory not found: ${distDir}`);
|
|
78
|
+
}
|
|
79
|
+
if (paths.length === 0) throw new Error(`Nothing to deploy in ${distDir}.`);
|
|
80
|
+
const form = new FormData();
|
|
81
|
+
let total = 0;
|
|
82
|
+
for (const path of paths) {
|
|
83
|
+
const data = readFileSync(path);
|
|
84
|
+
total += data.byteLength;
|
|
85
|
+
form.append(relative(distDir, path).replaceAll("\\", "/"), new Blob([data]));
|
|
86
|
+
}
|
|
87
|
+
const spin = p.spinner();
|
|
88
|
+
spin.start(`Deploying ${paths.length} files (${(total / 1024).toFixed(0)} kB) → ${slug}`);
|
|
89
|
+
let result;
|
|
90
|
+
try {
|
|
91
|
+
({deploy: result} = await apiRequest("POST", `/projects/${slug}/deploys`, form));
|
|
92
|
+
} catch (err) {
|
|
93
|
+
spin.error("Deploy failed.");
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
spin.stop(`Live: ${projectUrl(slug)} (v${result.version})`);
|
|
97
|
+
})
|
|
98
|
+
});
|
|
99
|
+
/** Show the deploy history and ask which version to make live. */
|
|
100
|
+
async function pickVersion(deploys, live) {
|
|
101
|
+
const fallback = live !== null && live > 1 ? live - 1 : void 0;
|
|
102
|
+
return unwrap(await p.select({
|
|
103
|
+
message: "Version to make live",
|
|
104
|
+
options: deploys.map((deploy) => ({
|
|
105
|
+
value: deploy.version,
|
|
106
|
+
label: `v${deploy.version} ${deploy.created_at} (${deploy.source})`,
|
|
107
|
+
hint: deploy.version === live ? "live" : void 0
|
|
108
|
+
})),
|
|
109
|
+
initialValue: fallback
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
const rollbackCommand = defineCommand({
|
|
113
|
+
meta: {
|
|
114
|
+
name: "rollback",
|
|
115
|
+
description: "Make an earlier deploy live again (pick from history)"
|
|
116
|
+
},
|
|
117
|
+
args: {
|
|
118
|
+
version: {
|
|
119
|
+
type: "positional",
|
|
120
|
+
description: "Deploy version to make live, e.g. v3 (picked from a list when omitted)",
|
|
121
|
+
required: false
|
|
122
|
+
},
|
|
123
|
+
...projectFlagArgs,
|
|
124
|
+
dir: {
|
|
125
|
+
type: "string",
|
|
126
|
+
description: "Project directory (defaults to the current directory)"
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
run: reportErrors("rollback", async ({ args }) => {
|
|
130
|
+
const { slug } = resolveProject(args);
|
|
131
|
+
let target;
|
|
132
|
+
if (args.version !== void 0) {
|
|
133
|
+
if (!/^v?\d+$/.test(args.version)) throw new Error(`Invalid version "${args.version}" — expected a number like 3 or v3.`);
|
|
134
|
+
target = Number(args.version.replace(/^v/, ""));
|
|
135
|
+
} else {
|
|
136
|
+
const { deploys, live } = await apiRequest("GET", `/projects/${slug}/deploys`);
|
|
137
|
+
if (deploys.length < 2) throw new Error("Nothing to roll back to — this project has fewer than two deploys.");
|
|
138
|
+
if (process.stdin.isTTY) {
|
|
139
|
+
target = await pickVersion(deploys, live);
|
|
140
|
+
if (target === live) throw new Error(`v${target} is already live.`);
|
|
141
|
+
} else {
|
|
142
|
+
if (live === null || live < 2) throw new Error("Nothing to roll back to — no deploy exists before the live one.");
|
|
143
|
+
target = live - 1;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
console.log(`Rolling back ${slug} to v${target}`);
|
|
147
|
+
const { live: result } = await apiRequest("POST", `/projects/${slug}/deploys/${target}/rollback`);
|
|
148
|
+
console.log(`Live: ${projectUrl(slug)} (v${result.version}, was v${result.previous ?? "?"})`);
|
|
149
|
+
})
|
|
150
|
+
});
|
|
151
|
+
//#endregion
|
|
152
|
+
export { deployCommand, rollbackCommand };
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { a as readConfig, n as reportErrors } from "./ui-Cl5P_yQ6.mjs";
|
|
2
|
+
import { t as apiRequest } from "./api-BEY4O4ap.mjs";
|
|
3
|
+
import { i as resolveProject, t as projectDirArgs } from "./project-CrrpLEPs.mjs";
|
|
4
|
+
import { defineCommand } from "citty";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
//#region src/dev.ts
|
|
7
|
+
/**
|
|
8
|
+
* Mirror the gateway's project-host rewrite (apps/gateway/src/index.ts):
|
|
9
|
+
* strip /_api, then scope bare data-plane module paths onto this project.
|
|
10
|
+
* Control-plane paths (and /v1/sdk.js) pass through untouched.
|
|
11
|
+
*/
|
|
12
|
+
function rewriteApiPath(path, slug) {
|
|
13
|
+
const queryStart = path.indexOf("?");
|
|
14
|
+
const query = queryStart === -1 ? "" : path.slice(queryStart);
|
|
15
|
+
let pathname = (queryStart === -1 ? path : path.slice(0, queryStart)).slice(5);
|
|
16
|
+
const dataPlane = /^\/v1\/(db|blob|ai|services)(\/.*)?$/.exec(pathname);
|
|
17
|
+
if (dataPlane) pathname = `/v1/projects/${slug}/${dataPlane[1]}${dataPlane[2] ?? ""}`;
|
|
18
|
+
return pathname + query;
|
|
19
|
+
}
|
|
20
|
+
async function startVite(flags, serverOverrides) {
|
|
21
|
+
const { createServer } = await import("vite");
|
|
22
|
+
const server = await createServer({
|
|
23
|
+
root: flags.dir,
|
|
24
|
+
plugins: [{
|
|
25
|
+
name: "canopy-platform-api-external",
|
|
26
|
+
resolveId: (id) => id.startsWith("/_api/") ? {
|
|
27
|
+
id,
|
|
28
|
+
external: true
|
|
29
|
+
} : void 0
|
|
30
|
+
}],
|
|
31
|
+
server: {
|
|
32
|
+
port: flags.port,
|
|
33
|
+
...serverOverrides
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
await server.listen();
|
|
37
|
+
return server;
|
|
38
|
+
}
|
|
39
|
+
async function devRemote(flags, slug) {
|
|
40
|
+
const { token, expires_at } = await apiRequest("POST", `/projects/${slug}/dev-token`);
|
|
41
|
+
const { apiUrl } = readConfig();
|
|
42
|
+
const server = await startVite(flags, { proxy: { "/_api": {
|
|
43
|
+
target: apiUrl,
|
|
44
|
+
changeOrigin: true,
|
|
45
|
+
headers: { authorization: `Bearer ${token}` },
|
|
46
|
+
rewrite: (path) => rewriteApiPath(path, slug)
|
|
47
|
+
} } });
|
|
48
|
+
console.log(`\n ${slug} — remote-first dev against LIVE project data`);
|
|
49
|
+
console.log(" (worried about a risky change? `canopy db snapshot` first, or work");
|
|
50
|
+
console.log(" on a local copy instead: `canopy dev --local --pull`)\n");
|
|
51
|
+
server.printUrls();
|
|
52
|
+
console.log(`\n Dev token expires ${expires_at} — restart canopy dev after that.`);
|
|
53
|
+
}
|
|
54
|
+
async function devLocal(flags, slug) {
|
|
55
|
+
let dump;
|
|
56
|
+
if (flags.pull) {
|
|
57
|
+
const { fetchProjectDump } = await import("./local-DhufX0eJ.mjs");
|
|
58
|
+
console.log(`Pulling a fresh export of ${slug}…`);
|
|
59
|
+
dump = await fetchProjectDump(slug);
|
|
60
|
+
}
|
|
61
|
+
let remote;
|
|
62
|
+
try {
|
|
63
|
+
const { token } = await apiRequest("POST", `/projects/${slug}/dev-token`);
|
|
64
|
+
remote = {
|
|
65
|
+
target: readConfig().apiUrl,
|
|
66
|
+
token
|
|
67
|
+
};
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.log(`Note: ai/services will be unavailable — could not reach the platform (${err instanceof Error ? err.message : String(err)}).`);
|
|
70
|
+
}
|
|
71
|
+
const { createSandbox } = await import("./local-DhufX0eJ.mjs");
|
|
72
|
+
const sandbox = await createSandbox({
|
|
73
|
+
slug,
|
|
74
|
+
persistDir: join(flags.dir, ".canopy", "local"),
|
|
75
|
+
email: readConfig().email
|
|
76
|
+
});
|
|
77
|
+
if (dump !== void 0) {
|
|
78
|
+
await sandbox.applyDump(dump);
|
|
79
|
+
console.log(`Hydrated the sandbox (${(dump.length / 1024).toFixed(1)} kB of SQL).`);
|
|
80
|
+
}
|
|
81
|
+
const handleApi = async (req, res) => {
|
|
82
|
+
const chunks = [];
|
|
83
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
84
|
+
const body = Buffer.concat(chunks);
|
|
85
|
+
const headers = new Headers();
|
|
86
|
+
if (req.headers["content-type"]) headers.set("content-type", req.headers["content-type"]);
|
|
87
|
+
const response = await sandbox.handle(new Request(`http://canopy.local${req.url ?? ""}`, {
|
|
88
|
+
method: req.method,
|
|
89
|
+
headers,
|
|
90
|
+
body: body.length === 0 ? void 0 : new Uint8Array(body)
|
|
91
|
+
}));
|
|
92
|
+
res.writeHead(response.status, Object.fromEntries(response.headers));
|
|
93
|
+
res.end(Buffer.from(await response.arrayBuffer()));
|
|
94
|
+
};
|
|
95
|
+
const { createServer: createHttpServer } = await import("node:http");
|
|
96
|
+
const apiServer = createHttpServer((req, res) => {
|
|
97
|
+
handleApi(req, res).catch((err) => {
|
|
98
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
99
|
+
res.end(JSON.stringify({
|
|
100
|
+
error: "local_sandbox_error",
|
|
101
|
+
detail: String(err)
|
|
102
|
+
}));
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
await new Promise((done) => apiServer.listen(0, "127.0.0.1", done));
|
|
106
|
+
const apiAddress = apiServer.address();
|
|
107
|
+
if (apiAddress === null || typeof apiAddress === "string") throw new Error("Could not bind the local sandbox server.");
|
|
108
|
+
const remoteEntry = remote && {
|
|
109
|
+
target: remote.target,
|
|
110
|
+
changeOrigin: true,
|
|
111
|
+
headers: { authorization: `Bearer ${remote.token}` },
|
|
112
|
+
rewrite: (path) => rewriteApiPath(path, slug)
|
|
113
|
+
};
|
|
114
|
+
const server = await startVite(flags, { proxy: {
|
|
115
|
+
...remoteEntry && {
|
|
116
|
+
"/_api/v1/ai": remoteEntry,
|
|
117
|
+
"/_api/v1/services": remoteEntry
|
|
118
|
+
},
|
|
119
|
+
"/_api": { target: `http://127.0.0.1:${apiAddress.port}` }
|
|
120
|
+
} });
|
|
121
|
+
server.httpServer?.once("close", () => {
|
|
122
|
+
apiServer.close();
|
|
123
|
+
sandbox.dispose();
|
|
124
|
+
});
|
|
125
|
+
console.log(`\n ${slug} — LOCAL sandbox (Miniflare D1/R2 in .canopy/local/)`);
|
|
126
|
+
console.log(" data is yours alone: db and blob writes never reach the platform");
|
|
127
|
+
if (dump === void 0) console.log(" (want prod data? restart with --pull to hydrate from a fresh export)");
|
|
128
|
+
console.log(" blobs are not pulled — the local blob store starts empty");
|
|
129
|
+
console.log(remote ? " ai/services pass through to the platform (metered as usual)" : " ai/services answer 501 until you log in and restart");
|
|
130
|
+
console.log("");
|
|
131
|
+
server.printUrls();
|
|
132
|
+
}
|
|
133
|
+
const devCommand = defineCommand({
|
|
134
|
+
meta: {
|
|
135
|
+
name: "dev",
|
|
136
|
+
description: "Run the project locally (remote-first; --local [--pull] for a sandbox)"
|
|
137
|
+
},
|
|
138
|
+
args: {
|
|
139
|
+
...projectDirArgs,
|
|
140
|
+
port: {
|
|
141
|
+
type: "string",
|
|
142
|
+
description: "Dev server port"
|
|
143
|
+
},
|
|
144
|
+
local: {
|
|
145
|
+
type: "boolean",
|
|
146
|
+
default: false,
|
|
147
|
+
description: "Local-data sandbox (Miniflare D1/R2 under .canopy/local/) instead of live data"
|
|
148
|
+
},
|
|
149
|
+
pull: {
|
|
150
|
+
type: "boolean",
|
|
151
|
+
default: false,
|
|
152
|
+
description: "Hydrate the sandbox from a fresh prod export first (requires --local)"
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
run: reportErrors("dev", async ({ args }) => {
|
|
156
|
+
const port = args.port === void 0 ? void 0 : Number(args.port);
|
|
157
|
+
if (port !== void 0 && !Number.isInteger(port)) throw new Error(`Invalid --port=${args.port}`);
|
|
158
|
+
if (args.pull && !args.local) throw new Error("--pull only makes sense with --local (remote dev already sees live data).");
|
|
159
|
+
const { dir, slug } = resolveProject(args);
|
|
160
|
+
const flags = {
|
|
161
|
+
dir,
|
|
162
|
+
port,
|
|
163
|
+
local: args.local,
|
|
164
|
+
pull: args.pull
|
|
165
|
+
};
|
|
166
|
+
await (flags.local ? devLocal(flags, slug) : devRemote(flags, slug));
|
|
167
|
+
})
|
|
168
|
+
});
|
|
169
|
+
//#endregion
|
|
170
|
+
export { rewriteApiPath as n, devCommand as t };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { defineCommand, runMain } from "citty";
|
|
3
|
+
//#region package.json
|
|
4
|
+
var version = "0.0.1";
|
|
5
|
+
//#endregion
|
|
6
|
+
//#region src/index.ts
|
|
7
|
+
/**
|
|
8
|
+
* canopy — the Canopy CLI.
|
|
9
|
+
*
|
|
10
|
+
* Commands are citty subcommands, lazily imported so startup stays snappy.
|
|
11
|
+
* Planned commands (docs/04-roadmap.md):
|
|
12
|
+
* login OneLogin sign-in (browser, PKCE) [phase 1]
|
|
13
|
+
* create scaffold + register a project [phase 1]
|
|
14
|
+
* dev [--local [--pull]] local dev: remote-first, or offline sandbox [phase 1/2]
|
|
15
|
+
* deploy build & deploy assets (+ worker) [phase 1/2]
|
|
16
|
+
* db pull|snapshot|restore [phase 2]
|
|
17
|
+
* logs tail the project's worker logs [phase 2]
|
|
18
|
+
* open open the deployed project / dashboard [phase 1]
|
|
19
|
+
*/
|
|
20
|
+
/** A roadmap command that has not landed yet (docs/04-roadmap.md). */
|
|
21
|
+
function planned(name, description, phase) {
|
|
22
|
+
return defineCommand({
|
|
23
|
+
meta: {
|
|
24
|
+
name,
|
|
25
|
+
description: `${description} (not implemented yet — Phase ${phase})`
|
|
26
|
+
},
|
|
27
|
+
run() {
|
|
28
|
+
console.error(`canopy ${name}: not implemented yet — lands in Phase ${phase} (docs/04-roadmap.md).`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
await runMain(defineCommand({
|
|
34
|
+
meta: {
|
|
35
|
+
name: "canopy",
|
|
36
|
+
version,
|
|
37
|
+
description: "canopy — create and deploy vibecoded projects"
|
|
38
|
+
},
|
|
39
|
+
subCommands: {
|
|
40
|
+
login: () => import("./login-DrXKOEmr.mjs").then((m) => m.loginCommand),
|
|
41
|
+
whoami: () => import("./login-DrXKOEmr.mjs").then((m) => m.whoamiCommand),
|
|
42
|
+
logout: () => import("./login-DrXKOEmr.mjs").then((m) => m.logoutCommand),
|
|
43
|
+
create: () => import("./projects-f_KocDae.mjs").then((m) => m.createCommand),
|
|
44
|
+
projects: () => import("./projects-f_KocDae.mjs").then((m) => m.projectsCommand),
|
|
45
|
+
delete: () => import("./projects-f_KocDae.mjs").then((m) => m.deleteCommand),
|
|
46
|
+
dev: () => import("./dev-Bvy21srH.mjs").then((m) => m.devCommand),
|
|
47
|
+
deploy: () => import("./deploy-BfhAlb91.mjs").then((m) => m.deployCommand),
|
|
48
|
+
rollback: () => import("./deploy-BfhAlb91.mjs").then((m) => m.rollbackCommand),
|
|
49
|
+
db: () => import("./db-BBcgT1md.mjs").then((m) => m.dbCommand),
|
|
50
|
+
open: () => import("./open-DFWvrLFc.mjs").then((m) => m.openCommand),
|
|
51
|
+
logs: planned("logs", "Tail worker logs", "2")
|
|
52
|
+
}
|
|
53
|
+
}));
|
|
54
|
+
//#endregion
|
|
55
|
+
export {};
|