@mediamonks/canopy-cli 0.7.7 → 0.7.9
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/{db-D3RKaLVU.mjs → db-8Etjmzxh.mjs} +2 -2
- package/dist/{deploy-ClioT9ZC.mjs → deploy-y75cgauK.mjs} +2 -2
- package/dist/dev-BYxSHUH6.mjs +2 -0
- package/dist/{dev-BbAVZcAR.mjs → dev-DtoZrexp.mjs} +63 -8
- package/dist/dev-worker-BZgtEM9V.mjs +313 -0
- package/dist/docs/cli-dev.md +19 -0
- package/dist/docs/cli-feedback.md +38 -14
- package/dist/docs/dev.md +19 -0
- package/dist/docs/overview-how-it-works.md +2 -1
- package/dist/docs/platform-client-access.md +2 -1
- package/dist/docs/platform-feedback.md +46 -11
- package/dist/docs/platform-members-and-access.md +2 -2
- package/dist/docs/platform-project-data.md +19 -9
- package/dist/{docs-DOiL2Sw-.mjs → docs-VmcoH1An.mjs} +2 -2
- package/dist/{egress-BkkG9Jzs.mjs → egress-CN_axsAN.mjs} +1 -1
- package/dist/feedback-DUuWXS_D.mjs +390 -0
- package/dist/index.mjs +24 -24
- package/dist/{local-BHxSZwUT.mjs → local-C5AhA4GM.mjs} +160 -6
- package/dist/{login-C1x1l3qg.mjs → login-D7dI8D9N.mjs} +28 -12
- package/dist/{logs-DuzM-_Rn.mjs → logs-DNiZquki.mjs} +1 -1
- package/dist/{migrations-DpWHuqkQ.mjs → migrations-C-5x6SYf.mjs} +1 -1
- package/dist/{open-CLSh6oez.mjs → open-DRPp4QJg.mjs} +1 -1
- package/dist/{package-BNiOibhj.mjs → package-CGrKr6IT.mjs} +1 -1
- package/dist/{projects-C9TBoiW7.mjs → projects-C6JQjVqg.mjs} +1 -1
- package/dist/{skill-DRTg-mJR.mjs → skill-CZP1AqQX.mjs} +2 -2
- package/dist/{staging-95xeXyZN.mjs → staging-Bw1D_J8M.mjs} +1 -1
- package/dist/templates/worker/.dev.vars.example +9 -0
- package/dist/templates/worker/AGENTS.md +7 -1
- package/dist/templates/worker/README.md +4 -1
- package/dist/templates/worker/_gitignore +1 -0
- package/dist/{token-BjlqT2Xj.mjs → token-D7zrIF8G.mjs} +1 -1
- package/dist/{ui-CpdtK7mt.mjs → ui-2mrJcjYC.mjs} +30 -1
- package/dist/{upgrade-BDaM4M6Y.mjs → upgrade-CmhSO_r6.mjs} +2 -2
- package/dist/{usage-wpew0a2M.mjs → usage-CX1Izj_l.mjs} +1 -1
- package/package.json +1 -1
- package/dist/dev-D6Sy15Wv.mjs +0 -2
- package/dist/feedback-BZG8E8zG.mjs +0 -258
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { c as resolveProject, o as projectFlagArgs } from "./site-BUlQR117.mjs";
|
|
2
|
-
import { i as unwrap,
|
|
3
|
-
import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-
|
|
2
|
+
import { i as unwrap, n as reportErrors, o as apiDataRequest, r as requireTty, u as withContext } from "./ui-2mrJcjYC.mjs";
|
|
3
|
+
import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-C-5x6SYf.mjs";
|
|
4
4
|
import { defineCommand } from "citty";
|
|
5
5
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
6
6
|
import * as p from "@clack/prompts";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { a as projectDirArgs, c as resolveProject, l as stagingUrl, n as siteConfig, o as projectFlagArgs, s as projectUrl, u as tryReadCanopyJson } from "./site-BUlQR117.mjs";
|
|
2
|
-
import { c as apiRequest, i as unwrap,
|
|
3
|
-
import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-
|
|
2
|
+
import { c as apiRequest, i as unwrap, n as reportErrors, o as apiDataRequest, s as apiDownload, u as withContext } from "./ui-2mrJcjYC.mjs";
|
|
3
|
+
import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-C-5x6SYf.mjs";
|
|
4
4
|
import { defineCommand } from "citty";
|
|
5
5
|
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { a as projectDirArgs, c as resolveProject, i as projectDialect, n as siteConfig, u as tryReadCanopyJson } from "./site-BUlQR117.mjs";
|
|
2
|
-
import { a as ApiError, c as apiRequest,
|
|
3
|
-
import { r as readMigrationFiles } from "./migrations-
|
|
2
|
+
import { a as ApiError, c as apiRequest, n as reportErrors, u as withContext } from "./ui-2mrJcjYC.mjs";
|
|
3
|
+
import { r as readMigrationFiles } from "./migrations-C-5x6SYf.mjs";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { defineCommand } from "citty";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
6
7
|
import { join } from "node:path";
|
|
7
8
|
import { pathToFileURL } from "node:url";
|
|
8
9
|
//#region src/dev.ts
|
|
@@ -15,6 +16,56 @@ function rewriteApiPath(path) {
|
|
|
15
16
|
return path.slice(5);
|
|
16
17
|
}
|
|
17
18
|
/**
|
|
19
|
+
* What raw SQL through the worker's env.DB speaks (D15): the canopy.json lock
|
|
20
|
+
* wins, then the engine actually answering — PGlite in the --local sandbox
|
|
21
|
+
* (local.ts's default), the site's own database otherwise.
|
|
22
|
+
*/
|
|
23
|
+
function workerDialect(flags) {
|
|
24
|
+
if (flags.dialect !== void 0) return flags.dialect;
|
|
25
|
+
if (flags.local) return "postgres";
|
|
26
|
+
return siteConfig().cloud === "gcp" ? "postgres" : "sqlite";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The project's own backend worker, served by the dev server the way the
|
|
30
|
+
* gateway serves it in production (dev-worker.ts). Returns the plugin plus
|
|
31
|
+
* the notice lines to print, or undefined when the project has no worker.
|
|
32
|
+
*/
|
|
33
|
+
async function workerPlugin(flags, slug) {
|
|
34
|
+
const entry = tryReadCanopyJson(flags.dir)?.worker;
|
|
35
|
+
if (entry === void 0) return void 0;
|
|
36
|
+
if (!existsSync(join(flags.dir, entry))) {
|
|
37
|
+
console.log(`note: canopy.json points "worker" at ${entry}, which does not exist — skipping.`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const { missingSecretNames, readDevVars, workerDevPlugin } = await import("./dev-worker-BZgtEM9V.mjs");
|
|
41
|
+
const secrets = readDevVars(flags.dir);
|
|
42
|
+
const email = siteConfig().email;
|
|
43
|
+
const plugin = workerDevPlugin({
|
|
44
|
+
dir: flags.dir,
|
|
45
|
+
entry,
|
|
46
|
+
slug,
|
|
47
|
+
channel: flags.staging ? "staging" : "live",
|
|
48
|
+
secrets,
|
|
49
|
+
user: email ? {
|
|
50
|
+
sub: "local-dev",
|
|
51
|
+
email,
|
|
52
|
+
name: "Local Dev"
|
|
53
|
+
} : null,
|
|
54
|
+
dialect: workerDialect(flags)
|
|
55
|
+
});
|
|
56
|
+
const notes = [` /api/* is served by ${entry} (edit and refresh; no build step)`];
|
|
57
|
+
if (Object.keys(secrets).length > 0) notes.push(` worker secrets from .dev.vars: ${Object.keys(secrets).sort().join(", ")}`);
|
|
58
|
+
const missing = missingSecretNames(((await apiRequest("GET", `/projects/${slug}/secrets`).catch(() => void 0))?.secrets ?? []).map((s) => s.key), Object.keys(secrets));
|
|
59
|
+
if (missing.length > 0) {
|
|
60
|
+
notes.push(` set in the dashboard but not in .dev.vars: ${missing.join(", ")}`);
|
|
61
|
+
notes.push(" (dashboard values are never downloaded — add local ones to .dev.vars)");
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
plugin,
|
|
65
|
+
notes
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
18
69
|
* Load Vite's `createServer`, preferring the Vite installed in the *project*
|
|
19
70
|
* over the copy bundled with the CLI. `bundled` reports which one we got.
|
|
20
71
|
*
|
|
@@ -43,7 +94,7 @@ async function loadCreateServer(dir) {
|
|
|
43
94
|
};
|
|
44
95
|
}
|
|
45
96
|
}
|
|
46
|
-
async function startVite(flags, serverOverrides) {
|
|
97
|
+
async function startVite(flags, serverOverrides, extraPlugins = []) {
|
|
47
98
|
const { createServer, bundled } = await loadCreateServer(flags.dir);
|
|
48
99
|
const plugins = [{
|
|
49
100
|
name: "canopy-platform-api-external",
|
|
@@ -61,7 +112,7 @@ async function startVite(flags, serverOverrides) {
|
|
|
61
112
|
});
|
|
62
113
|
const server = await createServer({
|
|
63
114
|
root: flags.dir,
|
|
64
|
-
plugins,
|
|
115
|
+
plugins: [...plugins, ...extraPlugins],
|
|
65
116
|
server: {
|
|
66
117
|
port: flags.port,
|
|
67
118
|
...serverOverrides
|
|
@@ -84,6 +135,7 @@ function devTokenContext(slug) {
|
|
|
84
135
|
async function devRemote(flags, slug) {
|
|
85
136
|
const { token, expires_at } = await withContext(apiRequest("POST", `/projects/${slug}/dev-token`), devTokenContext(slug));
|
|
86
137
|
const { apiUrl } = siteConfig();
|
|
138
|
+
const worker = await workerPlugin(flags, slug);
|
|
87
139
|
const server = await startVite(flags, { proxy: { "/_api": {
|
|
88
140
|
target: apiUrl,
|
|
89
141
|
changeOrigin: true,
|
|
@@ -92,7 +144,7 @@ async function devRemote(flags, slug) {
|
|
|
92
144
|
...flags.staging ? { "x-canopy-channel": "staging" } : {}
|
|
93
145
|
},
|
|
94
146
|
rewrite: rewriteApiPath
|
|
95
|
-
} } });
|
|
147
|
+
} } }, worker ? [worker.plugin] : []);
|
|
96
148
|
if (flags.staging) {
|
|
97
149
|
console.log(`\n ${slug} — remote-first dev against the STAGING channel's data`);
|
|
98
150
|
console.log(" (live data is untouched; `canopy staging sync` re-clones it from live)");
|
|
@@ -105,6 +157,7 @@ async function devRemote(flags, slug) {
|
|
|
105
157
|
console.log(" migrations apply on `canopy deploy`, never from dev — use --local");
|
|
106
158
|
console.log(" while the schema is in flux");
|
|
107
159
|
}
|
|
160
|
+
for (const note of worker?.notes ?? []) console.log(note);
|
|
108
161
|
console.log("");
|
|
109
162
|
server.printUrls();
|
|
110
163
|
console.log(`\n Dev token expires ${expires_at} — restart canopy dev after that.`);
|
|
@@ -112,7 +165,7 @@ async function devRemote(flags, slug) {
|
|
|
112
165
|
async function devLocal(flags, slug) {
|
|
113
166
|
let dump;
|
|
114
167
|
if (flags.pull) {
|
|
115
|
-
const { fetchProjectDump } = await import("./local-
|
|
168
|
+
const { fetchProjectDump } = await import("./local-C5AhA4GM.mjs");
|
|
116
169
|
console.log(`Pulling a fresh export of ${slug}${flags.staging ? " [staging]" : ""}…`);
|
|
117
170
|
dump = await fetchProjectDump(slug, flags.staging ? "staging" : "live");
|
|
118
171
|
}
|
|
@@ -127,7 +180,7 @@ async function devLocal(flags, slug) {
|
|
|
127
180
|
const reason = err instanceof ApiError ? err.explain().why : String(err);
|
|
128
181
|
console.log(`Note: ai/services will be unavailable — could not reach the platform (${reason}).`);
|
|
129
182
|
}
|
|
130
|
-
const { createSandbox } = await import("./local-
|
|
183
|
+
const { createSandbox } = await import("./local-C5AhA4GM.mjs");
|
|
131
184
|
const sandbox = await createSandbox({
|
|
132
185
|
slug,
|
|
133
186
|
persistDir: join(flags.dir, ".canopy", "local"),
|
|
@@ -176,6 +229,7 @@ async function devLocal(flags, slug) {
|
|
|
176
229
|
headers: { authorization: `Bearer ${remote.token}` },
|
|
177
230
|
rewrite: rewriteApiPath
|
|
178
231
|
};
|
|
232
|
+
const worker = await workerPlugin(flags, slug);
|
|
179
233
|
const server = await startVite(flags, { proxy: {
|
|
180
234
|
...remoteEntry && {
|
|
181
235
|
"/_api/data/v1/ai": remoteEntry,
|
|
@@ -185,7 +239,7 @@ async function devLocal(flags, slug) {
|
|
|
185
239
|
target: `http://127.0.0.1:${apiAddress.port}`,
|
|
186
240
|
rewrite: (path) => path
|
|
187
241
|
}
|
|
188
|
-
} });
|
|
242
|
+
} }, worker ? [worker.plugin] : []);
|
|
189
243
|
server.httpServer?.once("close", () => {
|
|
190
244
|
apiServer.close();
|
|
191
245
|
sandbox.dispose();
|
|
@@ -197,6 +251,7 @@ async function devLocal(flags, slug) {
|
|
|
197
251
|
if (dump === void 0) console.log(" (want prod data? restart with --pull to hydrate from a fresh export)");
|
|
198
252
|
console.log(" blobs are not pulled — the local blob store starts empty");
|
|
199
253
|
console.log(remote ? " ai/services pass through to the platform (metered as usual)" : " ai/services answer 501 until you log in and restart");
|
|
254
|
+
for (const note of worker?.notes ?? []) console.log(note);
|
|
200
255
|
console.log("");
|
|
201
256
|
server.printUrls();
|
|
202
257
|
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { relative, resolve, sep } from "node:path";
|
|
3
|
+
//#region src/dev-worker.ts
|
|
4
|
+
/**
|
|
5
|
+
* Paths that belong to the worker rather than the SPA — the gateway's rule
|
|
6
|
+
* (apps/gateway/src/backend.ts), kept identical so a route that works in dev
|
|
7
|
+
* is reachable in production.
|
|
8
|
+
*/
|
|
9
|
+
function isWorkerPath(pathname) {
|
|
10
|
+
return pathname === "/api" || pathname.startsWith("/api/");
|
|
11
|
+
}
|
|
12
|
+
/** The manual schedule tick, mirroring the shim's own path (docs/07). */
|
|
13
|
+
const SCHEDULED_PATH = "/__canopy/scheduled";
|
|
14
|
+
/**
|
|
15
|
+
* `.dev.vars` — local-only worker secrets, the wrangler-shaped file the repo
|
|
16
|
+
* already gitignores.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately NOT "pull the dashboard's secrets": there is no way to read a
|
|
19
|
+
* stored value back (apps/api/src/routes/secrets.ts lists names and
|
|
20
|
+
* timestamps only — values are AES-GCM at rest and injected into the worker
|
|
21
|
+
* runtime, nowhere else), and adding one would put every project's live
|
|
22
|
+
* third-party credentials on every laptop that runs `canopy dev`. North star
|
|
23
|
+
* principle 3, secure by default: a dev secret is a local secret.
|
|
24
|
+
*/
|
|
25
|
+
function parseDevVars(text) {
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
28
|
+
const line = raw.trim();
|
|
29
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
30
|
+
const withoutExport = line.startsWith("export ") ? line.slice(7).trim() : line;
|
|
31
|
+
const eq = withoutExport.indexOf("=");
|
|
32
|
+
if (eq <= 0) continue;
|
|
33
|
+
const key = withoutExport.slice(0, eq).trim();
|
|
34
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
|
35
|
+
let value = withoutExport.slice(eq + 1).trim();
|
|
36
|
+
if (value.startsWith("\"") && value.endsWith("\"") && value.length >= 2 || value.startsWith("'") && value.endsWith("'") && value.length >= 2) value = value.slice(1, -1);
|
|
37
|
+
out[key] = value;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/** Reads `.dev.vars` from the project dir; {} when there is none. */
|
|
42
|
+
function readDevVars(dir) {
|
|
43
|
+
try {
|
|
44
|
+
return parseDevVars(readFileSync(resolve(dir, ".dev.vars"), "utf8"));
|
|
45
|
+
} catch {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Dashboard secret names the local `.dev.vars` doesn't define. */
|
|
50
|
+
function missingSecretNames(remote, local) {
|
|
51
|
+
const have = new Set(local);
|
|
52
|
+
return remote.filter((name) => !have.has(name)).sort();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Port of the shim's rewriteCanopyUrl: a worker may call
|
|
56
|
+
* `CANOPY.fetch("/db/todos")`, `"/data/v1/db/todos"` or the `/_api/…` form the
|
|
57
|
+
* browser SDK uses — all three land on the same data-plane path.
|
|
58
|
+
*/
|
|
59
|
+
function rewriteCanopyUrl(raw, base) {
|
|
60
|
+
const root = base.replace(/\/$/, "");
|
|
61
|
+
let path;
|
|
62
|
+
let search = "";
|
|
63
|
+
try {
|
|
64
|
+
const url = new URL(raw, "http://canopy.local");
|
|
65
|
+
path = url.pathname;
|
|
66
|
+
search = url.search;
|
|
67
|
+
} catch {
|
|
68
|
+
path = raw.startsWith("/") ? raw : `/${raw}`;
|
|
69
|
+
}
|
|
70
|
+
if (path.startsWith("/_api/")) path = path.slice(5);
|
|
71
|
+
if (!path.startsWith("/data/v1/") && !path.startsWith("/control/v1/")) path = `/data/v1${path.startsWith("/") ? path : `/${path}`}`;
|
|
72
|
+
return root + path + search;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* env.CANOPY.fetch — the same rewriting the shim does, delivered through the
|
|
76
|
+
* dev server's `/_api` mount so the running mode decides what answers.
|
|
77
|
+
* Credentials stay in the proxy; nothing here carries a token.
|
|
78
|
+
*/
|
|
79
|
+
function createCanopyFetch(apiBase, slug, channel, fetchImpl = fetch) {
|
|
80
|
+
return async (input, init) => {
|
|
81
|
+
const url = rewriteCanopyUrl(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, apiBase());
|
|
82
|
+
const headers = new Headers(input instanceof Request ? input.headers : init?.headers);
|
|
83
|
+
headers.delete("host");
|
|
84
|
+
headers.delete("x-canopy-user-sub");
|
|
85
|
+
headers.delete("x-canopy-user-email");
|
|
86
|
+
headers.delete("x-canopy-user-name");
|
|
87
|
+
headers.delete("authorization");
|
|
88
|
+
headers.delete("cookie");
|
|
89
|
+
headers.set("x-canopy-project", slug);
|
|
90
|
+
if (channel === "staging") headers.set("x-canopy-channel", "staging");
|
|
91
|
+
const method = (input instanceof Request ? input.method : init?.method) ?? "GET";
|
|
92
|
+
const body = input instanceof Request ? input.body : init?.body;
|
|
93
|
+
const outbound = {
|
|
94
|
+
method,
|
|
95
|
+
headers,
|
|
96
|
+
body: body ?? void 0,
|
|
97
|
+
redirect: input instanceof Request ? input.redirect : init?.redirect
|
|
98
|
+
};
|
|
99
|
+
if (body) outbound.duplex = "half";
|
|
100
|
+
return fetchImpl(url, outbound);
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* env.DB over the data plane's raw-SQL escape hatch — the deployed worker
|
|
105
|
+
* holds a direct D1/Postgres handle, which a laptop has no credential for, so
|
|
106
|
+
* dev routes the same statements through `/db/sql` (the legacy uncapped shape
|
|
107
|
+
* the SDK uses). Reads are identical; writes report `rowCount: 0` because that
|
|
108
|
+
* shape returns rows, not a change count.
|
|
109
|
+
*/
|
|
110
|
+
function createDevDb(canopyFetch, dialect) {
|
|
111
|
+
return {
|
|
112
|
+
dialect,
|
|
113
|
+
query: async (text, params = []) => {
|
|
114
|
+
const response = await canopyFetch("/data/v1/db/sql", {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: { "content-type": "application/json" },
|
|
117
|
+
body: JSON.stringify({
|
|
118
|
+
query: text,
|
|
119
|
+
params
|
|
120
|
+
})
|
|
121
|
+
});
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
const detail = await response.text();
|
|
124
|
+
throw new Error(`env.DB query failed (HTTP ${response.status}): ${detail.slice(0, 300)}`);
|
|
125
|
+
}
|
|
126
|
+
const rows = await response.json();
|
|
127
|
+
return {
|
|
128
|
+
rows,
|
|
129
|
+
rowCount: rows.length
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** env.BLOB over the data plane's blob routes, for the same reason as env.DB. */
|
|
135
|
+
function createDevBlob(canopyFetch, prefix) {
|
|
136
|
+
const path = (key) => `/data/v1/blob/${encodeURIComponent(key)}`;
|
|
137
|
+
return {
|
|
138
|
+
prefix,
|
|
139
|
+
put: async (key, body, contentType) => {
|
|
140
|
+
const response = await canopyFetch(path(key), {
|
|
141
|
+
method: "PUT",
|
|
142
|
+
headers: { "content-type": contentType ?? "application/octet-stream" },
|
|
143
|
+
body
|
|
144
|
+
});
|
|
145
|
+
if (!response.ok) throw new Error(`env.BLOB put failed (HTTP ${response.status})`);
|
|
146
|
+
},
|
|
147
|
+
get: async (key) => {
|
|
148
|
+
const response = await canopyFetch(path(key));
|
|
149
|
+
if (response.status === 404) return null;
|
|
150
|
+
if (!response.ok) throw new Error(`env.BLOB get failed (HTTP ${response.status})`);
|
|
151
|
+
const contentType = response.headers.get("content-type") ?? void 0;
|
|
152
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
153
|
+
return contentType ? {
|
|
154
|
+
body: bytes,
|
|
155
|
+
contentType
|
|
156
|
+
} : { body: bytes };
|
|
157
|
+
},
|
|
158
|
+
list: async (sub) => {
|
|
159
|
+
const response = await canopyFetch(`/data/v1/blob${sub ? `?prefix=${encodeURIComponent(sub)}` : ""}`);
|
|
160
|
+
if (!response.ok) throw new Error(`env.BLOB list failed (HTTP ${response.status})`);
|
|
161
|
+
return (await response.json()).map(({ key, size }) => ({
|
|
162
|
+
key,
|
|
163
|
+
size
|
|
164
|
+
}));
|
|
165
|
+
},
|
|
166
|
+
delete: async (key) => {
|
|
167
|
+
const response = await canopyFetch(path(key), { method: "DELETE" });
|
|
168
|
+
if (!response.ok) throw new Error(`env.BLOB delete failed (HTTP ${response.status})`);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/** Gateway-stamped identity, honored in dev too so a request can pose as one. */
|
|
173
|
+
function userFromHeaders(headers, fallback) {
|
|
174
|
+
const sub = (headers.get("x-canopy-user-sub") ?? "").trim();
|
|
175
|
+
if (!sub) return fallback;
|
|
176
|
+
return {
|
|
177
|
+
sub,
|
|
178
|
+
email: (headers.get("x-canopy-user-email") ?? "").trim(),
|
|
179
|
+
name: (headers.get("x-canopy-user-name") ?? "").trim()
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The env object the worker sees, in the shim's own key order: secrets first,
|
|
184
|
+
* platform keys after, so a stray `.dev.vars` entry can never shadow
|
|
185
|
+
* `CANOPY`/`DB`/`USER` (the dashboard rejects those names outright —
|
|
186
|
+
* apps/api/src/secrets.ts isReservedSecretKey).
|
|
187
|
+
*/
|
|
188
|
+
function buildWorkerEnv(options, request, parts) {
|
|
189
|
+
const blobPrefix = `projects/${options.slug}/`;
|
|
190
|
+
return {
|
|
191
|
+
...options.secrets,
|
|
192
|
+
CANOPY: { fetch: parts.canopyFetch },
|
|
193
|
+
DB: parts.db,
|
|
194
|
+
BLOB: parts.blob,
|
|
195
|
+
USER: userFromHeaders(request.headers, options.user),
|
|
196
|
+
PROJECT_SLUG: options.slug,
|
|
197
|
+
BLOB_PREFIX: blobPrefix,
|
|
198
|
+
CANOPY_CHANNEL: options.channel
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Host to reach the dev server on from inside this process. Vite's default
|
|
203
|
+
* bind is `localhost`, which on this machine is IPv6-only — a hardcoded
|
|
204
|
+
* 127.0.0.1 gets ECONNREFUSED — so the bound address decides.
|
|
205
|
+
*/
|
|
206
|
+
function loopbackHost(bound) {
|
|
207
|
+
if (bound === "" || bound === "::" || bound === "0.0.0.0") return "127.0.0.1";
|
|
208
|
+
return bound.includes(":") ? `[${bound}]` : bound;
|
|
209
|
+
}
|
|
210
|
+
/** Vite-root-relative URL for ssrLoadModule ("worker/index.ts" → "/worker/index.ts"). */
|
|
211
|
+
function entryModuleId(dir, entry) {
|
|
212
|
+
return `/${relative(resolve(dir), resolve(dir, entry)).split(sep).join("/")}`;
|
|
213
|
+
}
|
|
214
|
+
async function toRequest(req, origin) {
|
|
215
|
+
const headers = new Headers();
|
|
216
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
217
|
+
if (value === void 0) continue;
|
|
218
|
+
for (const one of Array.isArray(value) ? value : [value]) headers.append(name, one);
|
|
219
|
+
}
|
|
220
|
+
const method = req.method ?? "GET";
|
|
221
|
+
let body;
|
|
222
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
223
|
+
const chunks = [];
|
|
224
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
225
|
+
const buffer = Buffer.concat(chunks);
|
|
226
|
+
if (buffer.length > 0) body = new Uint8Array(buffer);
|
|
227
|
+
}
|
|
228
|
+
return new Request(`${origin}${req.url ?? "/"}`, {
|
|
229
|
+
method,
|
|
230
|
+
headers,
|
|
231
|
+
body
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
async function sendResponse(res, response) {
|
|
235
|
+
const headers = {};
|
|
236
|
+
for (const [name, value] of response.headers) headers[name] = value;
|
|
237
|
+
res.writeHead(response.status, headers);
|
|
238
|
+
res.end(Buffer.from(await response.arrayBuffer()));
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Serves the project's worker from the Vite dev server. Installed `pre` so
|
|
242
|
+
* `/api/*` is answered before Vite's SPA fallback claims it — the same
|
|
243
|
+
* precedence the gateway gives the worker over static assets.
|
|
244
|
+
*/
|
|
245
|
+
function workerDevPlugin(options) {
|
|
246
|
+
const moduleId = entryModuleId(options.dir, options.entry);
|
|
247
|
+
let apiBase = "";
|
|
248
|
+
const canopyFetch = createCanopyFetch(() => apiBase, options.slug, options.channel);
|
|
249
|
+
return {
|
|
250
|
+
name: "canopy-project-worker",
|
|
251
|
+
configureServer(server) {
|
|
252
|
+
const load = async () => {
|
|
253
|
+
const handler = (await server.ssrLoadModule(moduleId)).default;
|
|
254
|
+
if (!handler || typeof handler.fetch !== "function") throw new Error(`${options.entry} must default-export an object with fetch() (Workers shape).`);
|
|
255
|
+
return handler;
|
|
256
|
+
};
|
|
257
|
+
server.middlewares.use((req, res, next) => {
|
|
258
|
+
const path = (req.url ?? "/").split("?")[0] ?? "/";
|
|
259
|
+
const scheduled = path === SCHEDULED_PATH;
|
|
260
|
+
if (!isWorkerPath(path) && !scheduled) return next();
|
|
261
|
+
(async () => {
|
|
262
|
+
const address = server.httpServer?.address();
|
|
263
|
+
if (apiBase === "" && address != null && typeof address === "object") apiBase = `http://${loopbackHost(address.address)}:${address.port}/_api`;
|
|
264
|
+
const request = await toRequest(req, `http://${req.headers.host ?? "localhost"}`);
|
|
265
|
+
const worker = await load();
|
|
266
|
+
const env = buildWorkerEnv(options, request, {
|
|
267
|
+
canopyFetch,
|
|
268
|
+
db: createDevDb(canopyFetch, options.dialect),
|
|
269
|
+
blob: createDevBlob(canopyFetch, `projects/${options.slug}/`)
|
|
270
|
+
});
|
|
271
|
+
if (scheduled) {
|
|
272
|
+
if (req.method !== "POST") return next();
|
|
273
|
+
if (typeof worker.scheduled !== "function") {
|
|
274
|
+
await sendResponse(res, Response.json({
|
|
275
|
+
error: "no_scheduled_handler",
|
|
276
|
+
detail: "export scheduled() next to fetch"
|
|
277
|
+
}, { status: 501 }));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const body = await request.json().catch(() => ({}));
|
|
281
|
+
const name = typeof body.name === "string" ? body.name : "";
|
|
282
|
+
if (!name) {
|
|
283
|
+
await sendResponse(res, Response.json({ error: "missing_name" }, { status: 400 }));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
env.USER = null;
|
|
287
|
+
await worker.scheduled({
|
|
288
|
+
name,
|
|
289
|
+
cron: typeof body.cron === "string" ? body.cron : "",
|
|
290
|
+
scheduledTime: typeof body.scheduledTime === "number" ? body.scheduledTime : Date.now(),
|
|
291
|
+
type: "scheduled"
|
|
292
|
+
}, env);
|
|
293
|
+
await sendResponse(res, Response.json({
|
|
294
|
+
ok: true,
|
|
295
|
+
name
|
|
296
|
+
}));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
await sendResponse(res, await worker.fetch(request, env));
|
|
300
|
+
})().catch((err) => {
|
|
301
|
+
if (err instanceof Error) server.ssrFixStacktrace(err);
|
|
302
|
+
console.error("worker request failed", err);
|
|
303
|
+
sendResponse(res, Response.json({
|
|
304
|
+
error: "worker_error",
|
|
305
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
306
|
+
}, { status: 500 })).catch(() => res.end());
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
//#endregion
|
|
313
|
+
export { missingSecretNames, readDevVars, workerDevPlugin };
|
package/dist/docs/cli-dev.md
CHANGED
|
@@ -37,6 +37,25 @@ canopy dev --local --pull # sandbox hydrated from a fresh prod export
|
|
|
37
37
|
|
|
38
38
|
With the lock in place, `--local` runs on an embedded SQLite engine instead — the same raw SQL, full-text search behavior, and `--pull` dumps as your deployed site. `"dialect": "postgres"` makes today's default explicit. Each engine keeps its own state under `.canopy/local/`, so switching the lock never corrupts data. The SQLite engine uses Node's built-in database and needs **Node 24+** (older Node builds lack full-text search); the CLI says so plainly if yours is older.
|
|
39
39
|
|
|
40
|
+
**Your backend worker.** If `canopy.json` names a `worker` entry (the worker scaffold does), `canopy dev` serves it too: requests to `/api/*` go to your worker, everything else to the SPA — the same split the platform makes once you deploy. There's no build step; save the file and the next request runs the new code.
|
|
41
|
+
|
|
42
|
+
Your worker gets the same `env` it gets in production: `env.CANOPY.fetch` for the SDK, `env.DB` and `env.BLOB` for raw database and file access, `env.USER` for the signed-in person, `env.PROJECT_SLUG`. They all follow the mode you're in — live project data by default, your sandbox under `--local`. Two differences worth knowing: `env.USER` is you (the account you logged in with), since there's no company login in front of a local server, and a write through `env.DB` reports no row count locally.
|
|
43
|
+
|
|
44
|
+
To tick a schedule by hand, post its name to the same path the platform uses:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
curl -X POST localhost:5173/__canopy/scheduled -d '{"name":"nightly-digest"}'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**Worker secrets.** Secrets you set in the dashboard are never downloaded — the platform can't hand a stored value back, and your laptop is not where a live API key belongs. Put local values in a `.dev.vars` file next to `canopy.json` instead:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
STRIPE_KEY=sk_test_local
|
|
54
|
+
SLACK_WEBHOOK="https://hooks.slack.com/services/…"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Your worker reads them exactly as it reads dashboard secrets (`c.env.STRIPE_KEY`). Keep `.dev.vars` out of git — the scaffolds already ignore it. At startup `canopy dev` lists the dashboard secret names your `.dev.vars` doesn't define, so you know what's still missing (names only, never values).
|
|
58
|
+
|
|
40
59
|
**`--pull`.** Before the sandbox boots, the CLI requests a fresh SQL export of the project's production database and applies it locally. Sync is pull-only — nothing ever pushes back. Blobs are not pulled; the local blob store starts empty.
|
|
41
60
|
|
|
42
61
|
**Schema migrations.** When `canopy.json` configures a `"migrations"` directory (see the [migrations guide](https://docs.canopy.website/guides/migrations)), the `--local` sandbox applies pending migrations on boot, so the local schema always matches the repo. Remote-first dev **never** applies migrations — it points at production, and schema changes ship with `canopy deploy`. While a schema change is in flux, work in `--local`.
|
|
@@ -10,12 +10,16 @@ ones.
|
|
|
10
10
|
## Usage
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
canopy feedback list --status
|
|
13
|
+
canopy feedback list --status investigate
|
|
14
14
|
canopy feedback show <id>
|
|
15
15
|
canopy feedback comment <id> "root cause: the upload grant expires too early"
|
|
16
|
+
canopy feedback status <id> investigated --comment "root cause: the grant expires too early"
|
|
17
|
+
canopy feedback list --status accepted
|
|
16
18
|
canopy feedback status <id> resolved --comment "fixed in v42"
|
|
17
19
|
canopy feedback mine
|
|
18
20
|
canopy feedback report "canopy deploy exits 1 after upload" --command "canopy deploy"
|
|
21
|
+
canopy feedback report "the header overlaps the logo" --attach ./shot.png
|
|
22
|
+
canopy feedback attach <id> ./after-fix.png --comment "fixed — see the shot"
|
|
19
23
|
```
|
|
20
24
|
|
|
21
25
|
## Subcommands
|
|
@@ -26,26 +30,46 @@ require project membership. `list` and `show` take `--json` for
|
|
|
26
30
|
machine-readable output.
|
|
27
31
|
|
|
28
32
|
- `list [--status <s>] [--kind <k>]` — the project's inbox. Statuses:
|
|
29
|
-
`
|
|
30
|
-
`feedback`, `docs`.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
`investigate`, `investigated`, `accepted`, `in_progress`, `resolved`,
|
|
34
|
+
`declined`; kinds: `bug`, `feedback`, `docs`. (Reports filed before triage
|
|
35
|
+
statuses existed sit in `new`, which you can still filter for.)
|
|
36
|
+
- `show <id> [--download <dir>]` — full detail: body, captured context (page
|
|
37
|
+
URL, deploy version, console errors — or CLI context), the screenshots on
|
|
38
|
+
the report, and the comment trail. `--download` saves the screenshots into
|
|
39
|
+
a directory so you can actually look at them.
|
|
33
40
|
- `comment <id> <text>` — append a comment (findings, questions, updates).
|
|
41
|
+
- `attach <id> <file> [--attach <more>] [--caption <text>] [--comment <text>]` —
|
|
42
|
+
add screenshots to an existing report. With `--comment`, the comment is
|
|
43
|
+
posted first and the screenshots hang off that trail entry — how you show
|
|
44
|
+
"here's what it looks like after the fix".
|
|
34
45
|
- `status <id> <new-status> [--comment <note>]` — move the ticket; the
|
|
35
|
-
transition is recorded on the trail.
|
|
46
|
+
transition is recorded on the trail. Moving to `investigated` needs the
|
|
47
|
+
findings: pass `--comment`, or comment first.
|
|
36
48
|
- `mine [--project <slug>] [--json]` — your own reports across all projects,
|
|
37
49
|
with current statuses.
|
|
38
|
-
- `report <text> [--kind bug|feedback] [--project <slug>] [--command <cmd>] [--error <output>]` —
|
|
50
|
+
- `report <text> [--kind bug|feedback] [--project <slug>] [--command <cmd>] [--error <output>] [--attach <file>] [--caption <text>]` —
|
|
39
51
|
file a new report. **Without `--project` it goes to the platform inbox**
|
|
40
52
|
(Canopy itself) — the right target when a `canopy` command misbehaves. The
|
|
41
53
|
CLI attaches its version, the active site, your platform, and the project
|
|
42
|
-
you were working in.
|
|
54
|
+
you were working in. Reports open as `investigate`; a project member may
|
|
55
|
+
pass `--status` to open one further along — `investigated` when the report
|
|
56
|
+
itself is the write-up of what you found, `accepted` when the person you are
|
|
57
|
+
working with asked for it.
|
|
43
58
|
|
|
44
59
|
## For agents
|
|
45
60
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
Two loops.
|
|
62
|
+
|
|
63
|
+
**Investigate:** `list --status investigate` is everything nobody has looked at
|
|
64
|
+
yet. Reproduce with the context in `show` (including the deploy version that
|
|
65
|
+
served the page), then `status <id> investigated --comment "<what you found>"`.
|
|
66
|
+
Stop there — say what it would take to fix and let the person decide.
|
|
67
|
+
|
|
68
|
+
**Build:** `list --status accepted` is what a person has asked for. Do the
|
|
69
|
+
work, then `resolve` with a note, or `declined` if it turns out to be a bad
|
|
70
|
+
idea. Set `accepted` yourself only when the person you are working with told
|
|
71
|
+
you to.
|
|
72
|
+
|
|
73
|
+
When a `canopy` command itself fails: rule out user error, check
|
|
74
|
+
`canopy feedback mine` for a duplicate, then `report` with the exact command
|
|
75
|
+
and error output.
|
package/dist/docs/dev.md
CHANGED
|
@@ -46,6 +46,25 @@ dialect in `canopy.json` (`"dialect": "sqlite"`) and the sandbox runs an
|
|
|
46
46
|
embedded SQLite engine instead, so the same SQL works locally and deployed —
|
|
47
47
|
see [canopy dev](https://docs.canopy.website/cli/dev).
|
|
48
48
|
|
|
49
|
+
## Backend worker
|
|
50
|
+
|
|
51
|
+
Projects with a `worker` entry in `canopy.json` get their backend locally too:
|
|
52
|
+
`/api/*` is served by your worker in both modes, with the same `env` it has in
|
|
53
|
+
production (`env.CANOPY`, `env.DB`, `env.BLOB`, `env.USER`). Edit and refresh —
|
|
54
|
+
there's no build step.
|
|
55
|
+
|
|
56
|
+
Secrets are the one thing that doesn't come from the dashboard. Stored values
|
|
57
|
+
are never downloadable, so put local ones in a `.dev.vars` file next to
|
|
58
|
+
`canopy.json`:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
STRIPE_KEY=sk_test_local
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Your worker reads them the same way it reads dashboard secrets
|
|
65
|
+
(`c.env.STRIPE_KEY`). `canopy dev` prints which dashboard secret names your
|
|
66
|
+
`.dev.vars` is missing. Keep the file out of git — the scaffolds ignore it.
|
|
67
|
+
|
|
49
68
|
## Which mode should an agent use?
|
|
50
69
|
|
|
51
70
|
- Iterating on UI or read-only features → `canopy dev` (live data, realistic).
|
|
@@ -29,7 +29,8 @@ address exists as soon as the project does.
|
|
|
29
29
|
Visitors to your project's URL hit OneLogin before they see anything. By
|
|
30
30
|
default any Monk can open it. From the project's Settings you can tighten that
|
|
31
31
|
to collaborators only, or add a shared client password so people outside Monks
|
|
32
|
-
can view it.
|
|
32
|
+
can view it. The password stays visible in Settings, so you can look it up and
|
|
33
|
+
share it again later. There is no "make it public" switch — that's by design.
|
|
33
34
|
|
|
34
35
|
## Deploys create versions
|
|
35
36
|
|
|
@@ -28,7 +28,8 @@ The unlock is deliberately narrow:
|
|
|
28
28
|
|
|
29
29
|
## Enabling it
|
|
30
30
|
|
|
31
|
-
Open the project's **Settings** tab and use the **
|
|
31
|
+
Open the project's **Settings** tab and use the **Who outside Monks can open
|
|
32
|
+
it** card:
|
|
32
33
|
|
|
33
34
|
1. Set a password. Pick something you're comfortable saying out loud on a
|
|
34
35
|
call — it's a shared secret, not a personal credential.
|