carboncanvas-cli 0.1.1 → 0.1.2

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/CONTRACT.md CHANGED
@@ -92,3 +92,115 @@ re-checks (a hand-crafted request can skip the CLI):
92
92
  server should still enforce (→ 400 / 413).
93
93
  - **Static-ness:** `index.html` at root, no server manifest (`.next/standalone`,
94
94
  `.output/server`, `functions/`, `server.js`/`server.mjs`, `_worker.js`).
95
+
96
+ ---
97
+
98
+ # CLI auth contract (`/api/cli-auth/*` — device-code flow)
99
+
100
+ Added 2026-07-08 (Wave B). Source of truth for `carboncanvas login`'s
101
+ browser-approval flow; the server half must match byte-for-byte. The paste-token
102
+ path (dashboard reveal panel → interactive prompt) remains as the fallback.
103
+ Designed to be reused by a future MCP grant flow — nothing in it is CLI-specific.
104
+
105
+ Works everywhere the CLI runs (laptop, Replit/Bolt cloud shell, container): the
106
+ CLI never receives a browser callback — it POLLS. The user approves in their
107
+ signed-in dashboard at `/activate`.
108
+
109
+ ## 1. `POST {serviceUrl}/api/cli-auth/start` — begin a grant (public, rate-limited)
110
+
111
+ Request body: `{}` (no fields yet; senders MUST send valid JSON).
112
+
113
+ `200` →
114
+ ```json
115
+ {
116
+ "deviceCode": "dc_<high-entropy secret, CLI-side only, never shown to the user>",
117
+ "userCode": "ABCD-1234",
118
+ "verifyUrl": "https://<host>/activate",
119
+ "expiresIn": 900,
120
+ "interval": 5
121
+ }
122
+ ```
123
+ `userCode`: 8 chars from an unambiguous uppercase alphabet (no `0/O/1/I`),
124
+ displayed and compared case-insensitively, hyphen optional on entry. Single-use,
125
+ expires with the grant. The server stores only a hash of `deviceCode`.
126
+
127
+ ## 2. `POST {serviceUrl}/api/cli-auth/poll` — CLI polls (public, rate-limited)
128
+
129
+ Request body: `{ "deviceCode": "dc_…" }`.
130
+
131
+ `200` responses by grant state:
132
+ - `{ "status": "pending" }` — keep polling at `interval` seconds.
133
+ - `{ "status": "approved", "token": "<session bearer token>" }` — ONE-SHOT: the
134
+ token is minted at claim time and the grant is consumed; a second poll after
135
+ this returns `{ "status": "expired" }`.
136
+ - `{ "status": "expired" }` — grant expired/denied/already-claimed; CLI restarts
137
+ or falls back to paste-token.
138
+
139
+ `429` → slow down; CLI doubles its interval for the next poll. Body error code:
140
+ `slow_down` on poll (device-flow convention); `start` uses the codebase-standard
141
+ `rate_limited`. The CLI branches only on the 429 status, never the code.
142
+ Unknown/garbage `deviceCode` → `{ "status": "expired" }` (no oracle).
143
+
144
+ ## 3. `POST {serviceUrl}/api/cli-auth/approve` — dashboard approves (session-authed)
145
+
146
+ Called by the `/activate` dashboard page with the signed-in CREATOR's bearer
147
+ session (`Authorization: Bearer <token>`). Guest/reviewer sessions are rejected.
148
+
149
+ Request body: `{ "userCode": "ABCD-1234" }` (case-insensitive, hyphen optional).
150
+
151
+ - `200 { "ok": true }` — grant bound to this account; the CLI's next poll claims it.
152
+ - `404 { error, message }` — unknown, expired, or already-used code.
153
+ - `401 / 403` — no session / not a creator session.
154
+
155
+ All error bodies use the `{ error, message }` shape.
156
+
157
+ ## CLI behavior (`carboncanvas login`)
158
+
159
+ 1. `start` → print `verifyUrl` + `userCode` prominently; attempt to open the
160
+ browser locally (best-effort, never fail on error — cloud shells have no
161
+ browser); poll per `interval` until `approved`/`expired` (honor 429 backoff).
162
+ 2. On `approved`: store the token in the token store keyed by `serviceUrl`
163
+ (existing mechanism), verify with `GET /api/entitlements`, greet.
164
+ 3. On `expired` or user abort: offer the paste-token fallback.
165
+
166
+ ## `carboncanvas init` (companion command; no new server surface)
167
+
168
+ Requires a stored token (runs the login flow inline if absent), then:
169
+ 1. Chooses the canvas (amended 2026-07-08 — `--canvas` / `--new` / picker):
170
+ - `--canvas <cnv_…>` — link an EXISTING canvas instead of creating. Verified
171
+ against `GET /api/canvases?includeArchived=1` (existing endpoint →
172
+ `{ canvases: CanvasView[] }`), matched on `id`. Not in the account → exit 1
173
+ ("check the ID in your dashboard"); archived → exit 1 (activate it first);
174
+ id without the `cnv_` prefix → exit 1 before any network call. Never POSTs.
175
+ - Plain `init`, account owns 0 non-archived canvases → create (step 1b), no
176
+ prompt — TTY or not (first-time fast path).
177
+ - Plain `init`, account owns ≥1 non-archived canvas (same GET, archived
178
+ excluded):
179
+ - TTY (stdin AND stdout): interactive picker — numbered `name id` list;
180
+ Enter = create new named `<resolved name>`, a number = link that one.
181
+ - non-TTY (agents/CI): NEVER guesses — prints the canvases (one
182
+ `<id> <name>` line each, stderr) and exits 1 telling the caller to
183
+ re-run with `--canvas <id>` or `--new`. Prevents surprise duplicates.
184
+ - `--new` — always create; skips the GET/picker/stop entirely. `--canvas`
185
+ and `--new` are mutually exclusive (exit 1).
186
+ 1b. Create: `POST /api/canvases { name }` (name from `--name` or
187
+ package.json `name`) — existing endpoint, existing auth. `402/403` →
188
+ surface `message` (plan gate).
189
+ Auto-relogin (amended 2026-07-08): a `401` from either call above triggers
190
+ ONE inline device-code login ("Your sign-in expired — let's renew it."),
191
+ then a single retry of the failed call; a second `401` surfaces the error.
192
+ One relogin per run, never a loop.
193
+ 2. Write `VITE_CC_CANVAS_ID=<id>` + `VITE_CC_BACKEND=<serviceUrl>` into `.env`
194
+ (create or append; NEVER overwrite an existing `VITE_CC_CANVAS_ID` — the id
195
+ is write-once; print the existing one and exit 1 instead), mirror the keys
196
+ into `.env.example` with placeholder values, ensure `.gitignore` covers `.env`.
197
+ Identical for created AND linked canvases (the write-once guard still runs
198
+ first, before any network call).
199
+ 3. Print: the canvas id/name, what was written, and "tell your coding agent the
200
+ canvas is registered — it wires the comments prop from these env vars
201
+ (see <serviceUrl>/skill.md)". The id is the one machine-readable stdout line.
202
+
203
+ Sibling command (added 2026-07-08): `carboncanvas canvases` — lists the
204
+ account's canvases via the same `GET /api/canvases?includeArchived=1` (same
205
+ auth + one-shot relogin); one `<id> <name>` line per canvas on stdout, with a
206
+ trailing ` [archived]` marker on soft-archived ones. No new server surface.
package/README.md CHANGED
@@ -19,20 +19,101 @@ npx carboncanvas publish
19
19
 
20
20
  ## `carboncanvas login`
21
21
 
22
- Verifies a creator API token (from the dashboard Settings → API tokens) against
23
- the backend and stores it in `~/.carboncanvas/config.json` (mode `0600`), keyed by
24
- backend URL.
22
+ Signs you in via your **browser** (device-code flow): the CLI prints an
23
+ activation link + a short code, opens the browser when it can, and waits while
24
+ you approve the sign-in on the dashboard's `/activate` page. This works from
25
+ anywhere — laptops, cloud shells (Replit/Bolt), containers — because the CLI
26
+ only polls; it never needs a callback. The resulting token is verified and
27
+ stored in `~/.carboncanvas/config.json` (mode `0600`), keyed by backend URL.
25
28
 
26
29
  ```bash
27
- carboncanvas login # prompts for a token (hidden input)
28
- carboncanvas login --token cc_xxx # non-interactive
30
+ carboncanvas login # browser approval (link + code)
31
+ carboncanvas login --token cc_xxx # paste-token, non-interactive
29
32
  ```
30
33
 
34
+ - **Paste-token fallback:** if the browser flow expires, is denied, or the
35
+ backend doesn't support it, the CLI falls back to the classic hidden prompt
36
+ for a creator API token (dashboard → Settings → API tokens). `--token`
37
+ skips the browser flow entirely.
31
38
  - `CARBON_TOKEN` overrides the stored token at use time.
32
39
  - `--service-url <url>` / `CARBON_SERVICE_URL` selects the backend
33
- (default `https://quilt-production-65f6.up.railway.app`).
40
+ (default `https://app.carboncanvas.design`).
34
41
  - `carboncanvas logout` removes the stored token; `carboncanvas whoami` checks it.
35
42
 
43
+ ## `carboncanvas init`
44
+
45
+ Registers (or links) a canvas for the current project and wires it into your
46
+ env files — run it once per project, then hand off to your coding agent.
47
+
48
+ ```bash
49
+ carboncanvas init # create or pick — see below
50
+ carboncanvas init --name my-app # explicit canvas name for a new canvas
51
+ carboncanvas init --canvas cnv_xxx # link an EXISTING canvas from your account
52
+ carboncanvas init --new # always create, never ask
53
+ ```
54
+
55
+ What it does, in order:
56
+
57
+ 1. **Ensures you're signed in** — if there's no stored token it runs the
58
+ browser sign-in inline first. If a stored token turns out to be expired
59
+ (the API answers 401), `init` renews it automatically: it runs the browser
60
+ sign-in once ("Your sign-in expired — let's renew it.") and retries the
61
+ failed call once; a second 401 surfaces as an error.
62
+ 2. **Chooses the canvas:**
63
+ - `--canvas <cnv_…>` — links that existing canvas instead of creating one.
64
+ It must be in *your* account (checked against `GET /api/canvases`); an id
65
+ that isn't yours, an archived canvas, or a malformed id (no `cnv_` prefix)
66
+ exits 1 with a clear message. Nothing is created.
67
+ - Plain `init` when your account owns **no** canvases — creates one
68
+ (`POST /api/canvases`; the server mints the `cnv_…` id). The name comes
69
+ from `--name`, else the nearest `package.json` `name` (walking up from the
70
+ project dir), else the directory name.
71
+ - Plain `init` when your account **already owns canvases**:
72
+ - **In a terminal (TTY):** an interactive picker lists them (numbered,
73
+ name + id; archived ones excluded). Press Enter to create a new canvas
74
+ with the resolved name, or type a number to link that existing one.
75
+ - **Non-interactive (coding agents, CI, piped output):** `init` will
76
+ *not* guess. It prints your canvases (one `<id> <name>` per line) and
77
+ exits 1 with instructions to re-run with `--canvas <id>` or `--new` —
78
+ so an agent can relay the choice instead of minting a surprise
79
+ duplicate. First-time accounts (zero canvases) still create
80
+ deterministically with no prompt.
81
+ - `--new` — always create; skips the picker and the non-interactive stop
82
+ entirely (no canvas listing round-trip).
83
+ 3. **Writes the env wiring** — carefully:
84
+ - `.env` — appends `VITE_CC_CANVAS_ID=<id>` and `VITE_CC_BACKEND=<url>`
85
+ (creates the file if absent, preserves everything already in it).
86
+ **Write-once:** if `VITE_CC_CANVAS_ID` already has a value, `init` touches
87
+ nothing, prints the existing id, and exits 1 — comments and review history
88
+ live on that id.
89
+ - `.env.example` — mirrors the two keys with placeholder values (skips keys
90
+ already present).
91
+ - `.gitignore` — adds a `.env` rule if none covers it.
92
+ 4. **Prints the handoff** — tell your coding agent the canvas is registered; it
93
+ wires the `comments` prop from these env vars (instructions at
94
+ `<serviceUrl>/skill.md`). The canvas id is the one machine-readable line on
95
+ stdout.
96
+
97
+ Flags: `--name <name>`, `--dir <dir>` (project directory, default cwd),
98
+ `--canvas <cnv_…>` (link an existing canvas), `--new` (always create), plus the
99
+ common `--service-url` / `--token`. `--canvas` and `--new` are mutually
100
+ exclusive.
101
+
102
+ ## `carboncanvas canvases`
103
+
104
+ Lists the canvases in your account — so you (or your coding agent) can pick an
105
+ id for `init --canvas` or `publish --canvas` at any time.
106
+
107
+ ```bash
108
+ carboncanvas canvases
109
+ ```
110
+
111
+ One canvas per stdout line, machine-readable: `<id> <name>` (two spaces;
112
+ `(unnamed)` if the canvas has no name), with a trailing ` [archived]` marker
113
+ on soft-archived ones. Headers and the empty-account message go to stderr, so
114
+ `carboncanvas canvases | grep cnv_` sees only canvas lines. Uses the same
115
+ sign-in as `init`, including the automatic renewal on an expired token.
116
+
36
117
  ## `carboncanvas publish`
37
118
 
38
119
  Runs, in order:
@@ -0,0 +1,102 @@
1
+ // Canvas registration + listing for `carboncanvas init` — POST /api/canvases
2
+ // and GET /api/canvases (existing endpoints, existing bearer auth; no new
3
+ // server surface — see CONTRACT.md "carboncanvas init"). The server mints the
4
+ // opaque `cnv_…` id on create; GET returns `{ canvases: CanvasView[] }`
5
+ // (server/src/routes/canvases.ts), non-archived unless `includeArchived`.
6
+ //
7
+ // Both calls throw AuthExpiredError on 401 so initCommand can run the
8
+ // device-code login once and retry (see initCommand's relogin wrapper).
9
+ import { AuthExpiredError, UserError } from './errors.js';
10
+ async function readErrorBody(res) {
11
+ try {
12
+ return (await res.json());
13
+ }
14
+ catch {
15
+ return {};
16
+ }
17
+ }
18
+ function unreachable(base, err) {
19
+ const detail = err instanceof Error ? err.message : String(err);
20
+ return new UserError(`Could not reach the Carbon Canvas backend at ${base} (${detail}). ` +
21
+ 'Check your network and --service-url, then retry.');
22
+ }
23
+ export async function createCanvas(serviceUrl, token, name, fetchImpl = fetch) {
24
+ const base = serviceUrl.replace(/\/+$/, '');
25
+ let res;
26
+ try {
27
+ res = await fetchImpl(`${base}/api/canvases`, {
28
+ method: 'POST',
29
+ headers: {
30
+ Authorization: `Bearer ${token}`,
31
+ 'content-type': 'application/json',
32
+ },
33
+ body: JSON.stringify({ name }),
34
+ });
35
+ }
36
+ catch (err) {
37
+ throw unreachable(base, err);
38
+ }
39
+ if (res.status === 200 || res.status === 201) {
40
+ const body = (await res.json());
41
+ if (!body.id) {
42
+ throw new UserError('The server accepted the canvas but returned no id. Try again.');
43
+ }
44
+ return { id: body.id, name: body.name ?? null };
45
+ }
46
+ const body = await readErrorBody(res);
47
+ switch (res.status) {
48
+ case 401:
49
+ throw new AuthExpiredError();
50
+ case 402:
51
+ throw new UserError(body.message ?? 'Creating another canvas is not included in your current plan.', 'Upgrade your plan on the dashboard (or archive a canvas), then retry.');
52
+ case 403:
53
+ throw new UserError(body.message ?? 'Your account is not allowed to create canvases.');
54
+ default:
55
+ throw new UserError(body.message ?? `Canvas registration failed with HTTP ${res.status}.`);
56
+ }
57
+ }
58
+ /**
59
+ * The account's own canvases (GET /api/canvases). Non-archived by default;
60
+ * `includeArchived` adds soft-archived ones (each carries `archivedAt`) so the
61
+ * `--canvas` link path can tell "archived" apart from "not yours".
62
+ */
63
+ export async function listCanvases(serviceUrl, token, fetchImpl = fetch, includeArchived = false) {
64
+ const base = serviceUrl.replace(/\/+$/, '');
65
+ const url = `${base}/api/canvases${includeArchived ? '?includeArchived=1' : ''}`;
66
+ let res;
67
+ try {
68
+ res = await fetchImpl(url, {
69
+ method: 'GET',
70
+ headers: { Authorization: `Bearer ${token}` },
71
+ });
72
+ }
73
+ catch (err) {
74
+ throw unreachable(base, err);
75
+ }
76
+ if (res.status === 200) {
77
+ let body;
78
+ try {
79
+ body = (await res.json());
80
+ }
81
+ catch {
82
+ throw new UserError('The server returned an unreadable canvas list. Try again.');
83
+ }
84
+ if (!Array.isArray(body.canvases)) {
85
+ throw new UserError('The server returned an unreadable canvas list. Try again.');
86
+ }
87
+ return body.canvases
88
+ .filter((c) => typeof c.id === 'string')
89
+ .map((c) => ({
90
+ id: c.id,
91
+ name: typeof c.name === 'string' ? c.name : null,
92
+ archivedAt: typeof c.archivedAt === 'string' ? c.archivedAt : null,
93
+ }));
94
+ }
95
+ if (res.status === 401) {
96
+ await readErrorBody(res); // drain
97
+ throw new AuthExpiredError();
98
+ }
99
+ const body = await readErrorBody(res);
100
+ throw new UserError(body.message ?? `Listing your canvases failed with HTTP ${res.status}.`);
101
+ }
102
+ //# sourceMappingURL=canvasApi.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"canvasApi.js","sourceRoot":"","sources":["../src/canvasApi.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,25 @@
1
+ // `carboncanvas canvases` — list the account's canvases so a user (or their
2
+ // coding agent) can pick an id for `init --canvas` / `publish --canvas` at any
3
+ // time. Same GET /api/canvases the init picker uses, same inline sign-in and
4
+ // one-shot relogin (session.ts).
5
+ //
6
+ // Output contract: ONE canvas per stdout line — `<id> <name>` (two spaces),
7
+ // `(unnamed)` when the canvas has no name, a trailing ` [archived]` marker on
8
+ // soft-archived ones. Headers/empty-state go to stderr, so
9
+ // `carboncanvas canvases | grep …` sees only canvas lines.
10
+ import * as log from './logger.js';
11
+ import { listCanvases } from './canvasApi.js';
12
+ import { openSession } from './session.js';
13
+ export async function canvasesCommand(opts) {
14
+ const session = await openSession(opts, '`carboncanvas canvases`');
15
+ const owned = await session.withRelogin((tok) => listCanvases(opts.serviceUrl, tok, opts.fetchImpl, true));
16
+ if (owned.length === 0) {
17
+ log.info(`No canvases on ${opts.serviceUrl} yet. Run \`carboncanvas init\` to create one.`);
18
+ return;
19
+ }
20
+ log.info(`Canvases on ${opts.serviceUrl} — <id> <name>:`);
21
+ for (const c of owned) {
22
+ log.out(`${c.id} ${c.name ?? '(unnamed)'}${c.archivedAt ? ' [archived]' : ''}`);
23
+ }
24
+ }
25
+ //# sourceMappingURL=canvasesCommand.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"canvasesCommand.js","sourceRoot":"","sources":["../src/canvasesCommand.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,+EAA+E;AAC/E,6EAA6E;AAC7E,iCAAiC;AACjC,EAAE;AACF,6EAA6E;AAC7E,+EAA+E;AAC/E,2DAA2D;AAC3D,2DAA2D;AAE3D,OAAO,KAAK,GAAG,MAAM,aAAa,CAAC;AACnC,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAoB,MAAM,cAAc,CAAC;AAI7D,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,IAAqB;IACzD,MAAM,OAAO,GAAG,MAAM,WAAW,CAAC,IAAI,EAAE,yBAAyB,CAAC,CAAC;IACnE,MAAM,KAAK,GAAG,MAAM,OAAO,CAAC,WAAW,CAAC,CAAC,GAAG,EAAE,EAAE,CAC9C,YAAY,CAAC,IAAI,CAAC,UAAU,EAAE,GAAG,EAAE,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,CACzD,CAAC;IAEF,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACvB,GAAG,CAAC,IAAI,CAAC,kBAAkB,IAAI,CAAC,UAAU,gDAAgD,CAAC,CAAC;QAC5F,OAAO;IACT,CAAC;IACD,GAAG,CAAC,IAAI,CAAC,eAAe,IAAI,CAAC,UAAU,kBAAkB,CAAC,CAAC;IAC3D,KAAK,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;QACtB,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,IAAI,IAAI,WAAW,GAAG,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACpF,CAAC;AACH,CAAC"}
package/dist/constants.js CHANGED
@@ -1,9 +1,15 @@
1
1
  // Shared constants for the Carbon Canvas CLI.
2
2
  //
3
- // The prod backend URL is the same Railway service the comments client talks to
4
- // (server/README.md: "running on Railway at https://quilt-production-65f6.up.railway.app").
5
- // It can be overridden per-invocation with `--service-url` or `CARBON_SERVICE_URL`.
6
- export const DEFAULT_SERVICE_URL = 'https://quilt-production-65f6.up.railway.app';
3
+ // The prod backend is the hosted Carbon Canvas service (a custom domain in front
4
+ // of the same Railway deployment the comments client talks to). It can be
5
+ // overridden per-invocation with `--service-url` or `CARBON_SERVICE_URL`.
6
+ export const DEFAULT_SERVICE_URL = 'https://app.carboncanvas.design';
7
+ // LEGACY: the Railway-generated URL the service shipped on before the
8
+ // app.carboncanvas.design custom domain existed. It still resolves (same
9
+ // backend), and tokens stored under this key by older CLI versions are honored
10
+ // via a read-time fallback in tokenStore — but it must never be presented to
11
+ // customers as the service URL, and writes never use this key.
12
+ export const LEGACY_SERVICE_URL = 'https://quilt-production-65f6.up.railway.app';
7
13
  // The command name a user types (bin name), used in help/error text.
8
14
  export const BIN_NAME = 'carboncanvas';
9
15
  // Client-side size caps (the server re-validates — these mirror plan §6b/B.3).
@@ -1 +1 @@
1
- {"version":3,"file":"constants.js","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,8CAA8C;AAC9C,EAAE;AACF,gFAAgF;AAChF,4FAA4F;AAC5F,oFAAoF;AACpF,MAAM,CAAC,MAAM,mBAAmB,GAAG,8CAA8C,CAAC;AAElF,qEAAqE;AACrE,MAAM,CAAC,MAAM,QAAQ,GAAG,cAAc,CAAC;AAEvC,+EAA+E;AAC/E,MAAM,CAAC,MAAM,gBAAgB,GAAG,EAAE,GAAG,IAAI,GAAG,IAAI,CAAC,CAAC,2BAA2B;AAC7E,MAAM,CAAC,MAAM,gBAAgB,GAAG,IAAI,CAAC;AAErC,4EAA4E;AAC5E,4EAA4E;AAC5E,MAAM,CAAC,MAAM,eAAe,GAAG,eAAe,CAAC;AAC/C,MAAM,CAAC,MAAM,gBAAgB,GAAG,aAAa,CAAC"}
1
+ {"version":3,"file":"constants.js","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,8CAA8C;AAC9C,EAAE;AACF,iFAAiF;AACjF,0EAA0E;AAC1E,0EAA0E;AAC1E,MAAM,CAAC,MAAM,mBAAmB,GAAG,iCAAiC,CAAC;AAErE,sEAAsE;AACtE,yEAAyE;AACzE,+EAA+E;AAC/E,6EAA6E;AAC7E,+DAA+D;AAC/D,MAAM,CAAC,MAAM,kBAAkB,GAAG,8CAA8C,CAAC;AAEjF,qEAAqE;AACrE,MAAM,CAAC,MAAM,QAAQ,GAAG,cAAc,CAAC;AAEvC,+EAA+E;AAC/E,MAAM,CAAC,MAAM,gBAAgB,GAAG,EAAE,GAAG,IAAI,GAAG,IAAI,CAAC,CAAC,2BAA2B;AAC7E,MAAM,CAAC,MAAM,gBAAgB,GAAG,IAAI,CAAC;AAErC,4EAA4E;AAC5E,4EAA4E;AAC5E,MAAM,CAAC,MAAM,eAAe,GAAG,eAAe,CAAC;AAC/C,MAAM,CAAC,MAAM,gBAAgB,GAAG,aAAa,CAAC"}
@@ -0,0 +1,137 @@
1
+ // Device-code login flow — the CLI half of the `/api/cli-auth/*` contract
2
+ // (see CONTRACT.md "CLI auth contract"). THE SHAPE HERE IS FIXED; the server is
3
+ // built to match it byte-for-byte.
4
+ //
5
+ // POST {serviceUrl}/api/cli-auth/start {} → { deviceCode, userCode, verifyUrl, expiresIn, interval }
6
+ // POST {serviceUrl}/api/cli-auth/poll { deviceCode } → { status: "pending" }
7
+ // | { status: "approved", token }
8
+ // | { status: "expired" }
9
+ // 429 on poll → slow down: double the interval for the next poll.
10
+ //
11
+ // The CLI never receives a browser callback — it POLLS, so the flow works in
12
+ // cloud shells (Replit/Bolt/containers) where no browser can reach us back.
13
+ import { spawn } from 'node:child_process';
14
+ // Hard ceiling on how long we poll, whatever `expiresIn` says (~15 min).
15
+ export const MAX_POLL_MS = 15 * 60 * 1000;
16
+ /**
17
+ * Begin a grant. Returns null when the backend doesn't speak the device-code
18
+ * flow (older self-hosted deploys, network trouble) so the caller can fall back
19
+ * to the paste-token prompt instead of dying.
20
+ */
21
+ export async function startDeviceGrant(serviceUrl, fetchImpl = fetch) {
22
+ const base = serviceUrl.replace(/\/+$/, '');
23
+ let res;
24
+ try {
25
+ res = await fetchImpl(`${base}/api/cli-auth/start`, {
26
+ method: 'POST',
27
+ headers: { 'content-type': 'application/json' },
28
+ body: JSON.stringify({}), // contract: senders MUST send valid JSON
29
+ });
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ if (res.status !== 200)
35
+ return null;
36
+ try {
37
+ const body = (await res.json());
38
+ if (!body.deviceCode || !body.userCode || !body.verifyUrl)
39
+ return null;
40
+ return {
41
+ deviceCode: body.deviceCode,
42
+ userCode: body.userCode,
43
+ verifyUrl: body.verifyUrl,
44
+ expiresIn: typeof body.expiresIn === 'number' ? body.expiresIn : 900,
45
+ interval: typeof body.interval === 'number' && body.interval > 0 ? body.interval : 5,
46
+ };
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
53
+ /**
54
+ * Poll `/api/cli-auth/poll` until the grant is approved or expired (or we hit
55
+ * the ~15 min ceiling). Honors 429 by doubling the poll interval.
56
+ */
57
+ export async function pollForApproval(serviceUrl, grant, opts = {}) {
58
+ const base = serviceUrl.replace(/\/+$/, '');
59
+ const fetchImpl = opts.fetchImpl ?? fetch;
60
+ const sleep = opts.sleep ?? realSleep;
61
+ const now = opts.now ?? Date.now;
62
+ const heartbeatEveryMs = opts.heartbeatEveryMs ?? 30_000;
63
+ const deadline = now() + Math.min(grant.expiresIn * 1000, MAX_POLL_MS);
64
+ let intervalMs = Math.max(1, grant.interval) * 1000;
65
+ let lastBeat = now();
66
+ while (now() < deadline) {
67
+ await sleep(intervalMs);
68
+ let res;
69
+ try {
70
+ res = await fetchImpl(`${base}/api/cli-auth/poll`, {
71
+ method: 'POST',
72
+ headers: { 'content-type': 'application/json' },
73
+ body: JSON.stringify({ deviceCode: grant.deviceCode }),
74
+ });
75
+ }
76
+ catch (err) {
77
+ const detail = err instanceof Error ? err.message : String(err);
78
+ return { status: 'unavailable', detail };
79
+ }
80
+ if (res.status === 429) {
81
+ intervalMs *= 2; // contract: slow down
82
+ continue;
83
+ }
84
+ if (res.status !== 200) {
85
+ return { status: 'unavailable', detail: `HTTP ${res.status}` };
86
+ }
87
+ let body;
88
+ try {
89
+ body = (await res.json());
90
+ }
91
+ catch {
92
+ return { status: 'unavailable', detail: 'unparseable poll response' };
93
+ }
94
+ if (body.status === 'approved' && body.token) {
95
+ return { status: 'approved', token: body.token };
96
+ }
97
+ if (body.status === 'pending') {
98
+ if (opts.heartbeat && now() - lastBeat >= heartbeatEveryMs) {
99
+ opts.heartbeat('Still waiting for approval in the browser …');
100
+ lastBeat = now();
101
+ }
102
+ continue;
103
+ }
104
+ // "expired" and anything unrecognized: the grant is gone (no oracle).
105
+ return { status: 'expired' };
106
+ }
107
+ return { status: 'expired' };
108
+ }
109
+ /**
110
+ * Best-effort browser open. Cloud shells have no browser — every failure is
111
+ * swallowed silently; the printed link + code are the real UX.
112
+ */
113
+ export function openBrowser(url) {
114
+ try {
115
+ let cmd;
116
+ let args;
117
+ if (process.platform === 'darwin') {
118
+ cmd = 'open';
119
+ args = [url];
120
+ }
121
+ else if (process.platform === 'win32') {
122
+ cmd = 'cmd';
123
+ args = ['/c', 'start', '""', url];
124
+ }
125
+ else {
126
+ cmd = 'xdg-open';
127
+ args = [url];
128
+ }
129
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
130
+ child.on('error', () => undefined);
131
+ child.unref();
132
+ }
133
+ catch {
134
+ // No browser here — fine. The user has the link and code on screen.
135
+ }
136
+ }
137
+ //# sourceMappingURL=deviceAuth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deviceAuth.js","sourceRoot":"","sources":["../src/deviceAuth.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,110 @@
1
+ // Env-file surgery for `carboncanvas init`. This touches files in the
2
+ // CUSTOMER'S repo, so the rules are strict:
3
+ //
4
+ // .env — append the two VITE_CC_* lines, preserving everything
5
+ // else byte-for-byte (create if absent). If a
6
+ // VITE_CC_CANVAS_ID with a value already exists, touch
7
+ // NOTHING — the id is write-once (comments live on it).
8
+ // .env.example — mirror the two keys with placeholder values (create or
9
+ // append; keys already present are left alone).
10
+ // .gitignore — ensure a `.env` rule exists (append or create).
11
+ //
12
+ // All functions are pure fs against a project root — no network, no prompts.
13
+ import { promises as fs } from 'node:fs';
14
+ import * as path from 'node:path';
15
+ export const ENV_CANVAS_KEY = 'VITE_CC_CANVAS_ID';
16
+ export const ENV_BACKEND_KEY = 'VITE_CC_BACKEND';
17
+ async function readIfExists(p) {
18
+ try {
19
+ return await fs.readFile(p, 'utf8');
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ // Find `KEY=value` in dotenv-style content. Returns the (unquoted, trimmed)
26
+ // value of the first assignment, or null when the key isn't assigned at all.
27
+ function envValue(content, key) {
28
+ const re = new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=(.*)$`);
29
+ for (const line of content.split(/\r?\n/)) {
30
+ const m = line.match(re);
31
+ if (m) {
32
+ let value = m[1].trim();
33
+ if ((value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
34
+ (value.startsWith("'") && value.endsWith("'") && value.length >= 2)) {
35
+ value = value.slice(1, -1);
36
+ }
37
+ return value;
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+ function hasEnvKey(content, key) {
43
+ return envValue(content, key) !== null;
44
+ }
45
+ /**
46
+ * The write-once guard: the canvas id already wired into `.env`, if any.
47
+ * An assignment with an EMPTY value doesn't count — only a real id blocks init.
48
+ */
49
+ export async function existingCanvasId(root) {
50
+ const content = await readIfExists(path.join(root, '.env'));
51
+ if (content === null)
52
+ return null;
53
+ const value = envValue(content, ENV_CANVAS_KEY);
54
+ return value ? value : null;
55
+ }
56
+ // Append `block` to a file, preserving existing content and making sure the
57
+ // block starts on its own line. Returns the action taken.
58
+ async function appendBlock(p, block) {
59
+ const existing = await readIfExists(p);
60
+ if (existing === null) {
61
+ await fs.writeFile(p, block);
62
+ return 'created';
63
+ }
64
+ const glue = existing === '' || existing.endsWith('\n') ? '' : '\n';
65
+ await fs.writeFile(p, existing + glue + block);
66
+ return 'appended';
67
+ }
68
+ /**
69
+ * Write the canvas wiring. Throws if a canvas id is already present with a
70
+ * value (callers should check `existingCanvasId` first — this re-check is the
71
+ * last line of defense against clobbering a live canvas binding).
72
+ */
73
+ export async function writeEnvFiles(root, canvasId, serviceUrl) {
74
+ const already = await existingCanvasId(root);
75
+ if (already) {
76
+ throw new Error(`Refusing to write: ${ENV_CANVAS_KEY} already set (${already}).`);
77
+ }
78
+ // 1. .env — the real values.
79
+ const envBlock = '# Carbon Canvas (added by `carboncanvas init`)\n' +
80
+ `${ENV_CANVAS_KEY}=${canvasId}\n` +
81
+ `${ENV_BACKEND_KEY}=${serviceUrl}\n`;
82
+ const env = await appendBlock(path.join(root, '.env'), envBlock);
83
+ // 2. .env.example — placeholders only, and only for keys not already there.
84
+ const examplePath = path.join(root, '.env.example');
85
+ const exampleContent = (await readIfExists(examplePath)) ?? '';
86
+ const missing = [];
87
+ if (!hasEnvKey(exampleContent, ENV_CANVAS_KEY)) {
88
+ missing.push(`${ENV_CANVAS_KEY}=cnv_your_canvas_id`);
89
+ }
90
+ if (!hasEnvKey(exampleContent, ENV_BACKEND_KEY)) {
91
+ missing.push(`${ENV_BACKEND_KEY}=${serviceUrl}`);
92
+ }
93
+ let envExample = 'unchanged';
94
+ if (missing.length > 0) {
95
+ envExample = await appendBlock(examplePath, '# Carbon Canvas (run `carboncanvas init` to fill these in)\n' + missing.join('\n') + '\n');
96
+ }
97
+ // 3. .gitignore — make sure `.env` never gets committed.
98
+ const gitignorePath = path.join(root, '.gitignore');
99
+ const gitignoreContent = await readIfExists(gitignorePath);
100
+ const covered = (gitignoreContent ?? '')
101
+ .split(/\r?\n/)
102
+ .map((l) => l.trim())
103
+ .some((l) => l === '.env' || l === '/.env' || l === '.env*' || l === '*.env');
104
+ let gitignore = 'unchanged';
105
+ if (!covered) {
106
+ gitignore = await appendBlock(gitignorePath, '.env\n');
107
+ }
108
+ return { env, envExample, gitignore };
109
+ }
110
+ //# sourceMappingURL=envFiles.js.map