@storystick/cli 0.0.0-stage → 0.2.0-rc.0
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/CONTENT-IMPORT.md +349 -0
- package/LICENSE +112 -0
- package/PUBLIC-API.md +30 -0
- package/README.md +263 -2
- package/assets/next-v1/app/[[...pathname]]/error.tsx +10 -0
- package/assets/next-v1/app/[[...pathname]]/not-found.tsx +8 -0
- package/assets/next-v1/app/[[...pathname]]/page.tsx +38 -0
- package/assets/next-v1/app/api/storystick/[...action]/route.ts +4 -0
- package/assets/next-v1/app/layout.tsx +28 -0
- package/assets/next-v1/app/sitemap.ts +3 -0
- package/assets/next-v1/env.local.example +13 -0
- package/assets/next-v1/globals.css +17 -0
- package/assets/next-v1/lib/site.ts +10 -0
- package/assets/next-v1/next.config.ts +4 -0
- package/assets/skills/storystick-development/SKILL.md +79 -0
- package/assets/skills/storystick-development/references/setup.md +152 -0
- package/assets/skills/storystick-development/references/story-and-next.md +49 -0
- package/dist/_backfill-recommendation.d.ts +12 -0
- package/dist/_backfill-recommendation.js +18 -0
- package/dist/asset-import.d.ts +50 -0
- package/dist/asset-import.js +160 -0
- package/dist/assistant-read-client.d.ts +3 -0
- package/dist/assistant-read-client.js +8 -0
- package/dist/auth-device.d.ts +153 -0
- package/dist/auth-device.js +390 -0
- package/dist/bootstrap-client.d.ts +52 -0
- package/dist/bootstrap-client.js +19 -0
- package/dist/cli-error.d.ts +54 -0
- package/dist/cli-error.js +107 -0
- package/dist/cli.d.ts +68 -0
- package/dist/cli.js +1520 -0
- package/dist/commands/archive.d.ts +17 -0
- package/dist/commands/archive.js +86 -0
- package/dist/commands/backfill-refs.d.ts +54 -0
- package/dist/commands/backfill-refs.js +217 -0
- package/dist/commands/connect.d.ts +11 -0
- package/dist/commands/connect.js +124 -0
- package/dist/commands/connection.d.ts +57 -0
- package/dist/commands/connection.js +83 -0
- package/dist/commands/content.d.ts +11 -0
- package/dist/commands/content.js +107 -0
- package/dist/commands/delivery.d.ts +22 -0
- package/dist/commands/delivery.js +99 -0
- package/dist/commands/info.d.ts +35 -0
- package/dist/commands/info.js +62 -0
- package/dist/commands/list.d.ts +28 -0
- package/dist/commands/list.js +116 -0
- package/dist/commands/migrate.d.ts +80 -0
- package/dist/commands/migrate.js +550 -0
- package/dist/commands/release.d.ts +8 -0
- package/dist/commands/release.js +60 -0
- package/dist/commands/setup.d.ts +6 -0
- package/dist/commands/setup.js +58 -0
- package/dist/commands/slice-previews.d.ts +17 -0
- package/dist/commands/slice-previews.js +233 -0
- package/dist/commands/source.d.ts +82 -0
- package/dist/commands/source.js +192 -0
- package/dist/content-import.d.ts +78 -0
- package/dist/content-import.js +210 -0
- package/dist/generated-transaction.d.ts +22 -0
- package/dist/generated-transaction.js +330 -0
- package/dist/hub-client.d.ts +72 -0
- package/dist/hub-client.js +178 -0
- package/dist/init-operation.d.ts +98 -0
- package/dist/init-operation.js +427 -0
- package/dist/init-readiness.d.ts +10 -0
- package/dist/init-readiness.js +50 -0
- package/dist/init-scaffold.d.ts +54 -0
- package/dist/init-scaffold.js +439 -0
- package/dist/init.d.ts +51 -0
- package/dist/init.js +789 -0
- package/dist/mcp-execution.d.ts +15 -0
- package/dist/mcp-execution.js +90 -0
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.js +41 -0
- package/dist/migration-artifact.d.ts +13 -0
- package/dist/migration-artifact.js +154 -0
- package/dist/operations-client.d.ts +137 -0
- package/dist/operations-client.js +130 -0
- package/dist/output.d.ts +347 -0
- package/dist/output.js +771 -0
- package/dist/project-access.d.ts +18 -0
- package/dist/project-access.js +1 -0
- package/dist/project-connection.d.ts +62 -0
- package/dist/project-connection.js +347 -0
- package/dist/project-execution-client.d.ts +2 -0
- package/dist/project-execution-client.js +33 -0
- package/dist/push-client.d.ts +100 -0
- package/dist/push-client.js +160 -0
- package/dist/release-context.d.ts +13 -0
- package/dist/release-context.js +39 -0
- package/dist/release-schema-client.d.ts +16 -0
- package/dist/release-schema-client.js +33 -0
- package/dist/release-sync.d.ts +36 -0
- package/dist/release-sync.js +31 -0
- package/dist/resolve.d.ts +15 -0
- package/dist/resolve.js +80 -0
- package/dist/runtime-config.d.ts +18 -0
- package/dist/runtime-config.js +86 -0
- package/dist/schema-export.d.ts +10 -0
- package/dist/schema-export.js +28 -0
- package/dist/setup.d.ts +65 -0
- package/dist/setup.js +135 -0
- package/dist/source-git.d.ts +36 -0
- package/dist/source-git.js +197 -0
- package/dist/starter-resolver.d.ts +82 -0
- package/dist/starter-resolver.js +495 -0
- package/dist/sync-transaction.d.ts +29 -0
- package/dist/sync-transaction.js +159 -0
- package/dist/transport.d.ts +8 -0
- package/dist/transport.js +73 -0
- package/examples/sanity/README.md +79 -0
- package/examples/sanity/convert.mjs +228 -0
- package/examples/sanity/fixture/data.ndjson +7 -0
- package/examples/sanity/fixture/mapping.json +44 -0
- package/examples/sanity/fixture/originals/mark.svg +1 -0
- package/examples/sanity/stories/articleBody.story +23 -0
- package/examples/sanity/stories/copy.story +7 -0
- package/examples/sanity/stories/page.story +19 -0
- package/examples/sanity/stories/picture.story +7 -0
- package/examples/sanity/stories/settings.story +9 -0
- package/examples/sanity/stories/stack.story +10 -0
- package/package.json +75 -3
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI device-auth client (SPEC-1 Item #2).
|
|
3
|
+
*
|
|
4
|
+
* Standard device-auth flow from the CLI's perspective:
|
|
5
|
+
*
|
|
6
|
+
* 1. POST /api/auth/device/start → receive deviceCode + userCode + URL
|
|
7
|
+
* 2. Print URL + userCode to the terminal
|
|
8
|
+
* 3. Poll /api/auth/device/poll every 3 seconds with deviceCode
|
|
9
|
+
* 4. On status=authorized, persist the returned JWT to
|
|
10
|
+
* `~/.config/storystick/auth-<issuer-hash>.json` (chmod 600) and return
|
|
11
|
+
* 5. On status=expired: fail with a clear message
|
|
12
|
+
* 6. On user Ctrl-C: fail quietly, no token written
|
|
13
|
+
*
|
|
14
|
+
* Token file layout (spec §Token storage path):
|
|
15
|
+
*
|
|
16
|
+
* {
|
|
17
|
+
* "issuer": "https://api.storystick.app",
|
|
18
|
+
* "token": "eyJ...", ← CLI JWT (typ: "cli"), 30d
|
|
19
|
+
* "expiresAt": 1735689600000, ← epoch-ms, matches JWT `exp`
|
|
20
|
+
* "userId": "user_xyz", ← for info/whoami without a ping
|
|
21
|
+
* "email": "you@…" ← cached, not security-sensitive
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* File location follows XDG:
|
|
25
|
+
* - `$XDG_CONFIG_HOME/storystick/auth-<issuer-hash>.json` if set
|
|
26
|
+
* - else `~/.config/storystick/auth-<issuer-hash>.json`
|
|
27
|
+
*
|
|
28
|
+
* Permissions:
|
|
29
|
+
* - Parent dir: 0o700 (user-only read/write/execute)
|
|
30
|
+
* - File: 0o600 (user-only read/write)
|
|
31
|
+
*
|
|
32
|
+
* This is the sole CLI user-session store. Project API keys remain separate
|
|
33
|
+
* credentials and never stand in for user authority.
|
|
34
|
+
*/
|
|
35
|
+
import { existsSync, chmodSync, closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, unlinkSync, } from 'node:fs';
|
|
36
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
37
|
+
import { homedir } from 'node:os';
|
|
38
|
+
import { join } from 'node:path';
|
|
39
|
+
import { cliIssuerRequestUrl, DEFAULT_GATEWAY_URL, normalizeCliIssuer, resolveCliRuntimeConfig } from './runtime-config.js';
|
|
40
|
+
import { abortableSleep, fetchWithDeadline } from './transport.js';
|
|
41
|
+
import { StorystickError, storystickErrorFromResponse } from './cli-error.js';
|
|
42
|
+
// ─────────────────────────────────────────────────────────────
|
|
43
|
+
// Constants
|
|
44
|
+
// ─────────────────────────────────────────────────────────────
|
|
45
|
+
/** 3-second polling cadence — same as Convex CLI / GitHub CLI. */
|
|
46
|
+
const POLL_INTERVAL_MS = 3000;
|
|
47
|
+
// ─────────────────────────────────────────────────────────────
|
|
48
|
+
// Token file location
|
|
49
|
+
// ─────────────────────────────────────────────────────────────
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the token-file directory per XDG. Public so tests can stub
|
|
52
|
+
* HOME + XDG env vars and assert the resolved path.
|
|
53
|
+
*/
|
|
54
|
+
export function authDir() {
|
|
55
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
56
|
+
if (xdg && xdg.length > 0)
|
|
57
|
+
return join(xdg, 'storystick');
|
|
58
|
+
return join(homedir(), '.config', 'storystick');
|
|
59
|
+
}
|
|
60
|
+
export function authFilePath(issuer) {
|
|
61
|
+
const identity = normalizeCliIssuer(issuer);
|
|
62
|
+
const key = createHash('sha256').update(identity).digest('hex');
|
|
63
|
+
return join(authDir(), `auth-${key}.json`);
|
|
64
|
+
}
|
|
65
|
+
// ─────────────────────────────────────────────────────────────
|
|
66
|
+
// Reading / writing the token file
|
|
67
|
+
// ─────────────────────────────────────────────────────────────
|
|
68
|
+
/**
|
|
69
|
+
* Read the cached CLI session. Returns null when:
|
|
70
|
+
* - file does not exist
|
|
71
|
+
* - file is unreadable (permissions / corruption)
|
|
72
|
+
* - JSON is malformed
|
|
73
|
+
* - required fields are missing
|
|
74
|
+
* - `expiresAt` has passed (local preflight — the Hub is the source of truth)
|
|
75
|
+
*/
|
|
76
|
+
export function getAuthToken(issuer) {
|
|
77
|
+
const stored = loadAuth(issuer);
|
|
78
|
+
if (!stored)
|
|
79
|
+
return null;
|
|
80
|
+
if (stored.expiresAt <= Date.now())
|
|
81
|
+
return null;
|
|
82
|
+
return stored.token;
|
|
83
|
+
}
|
|
84
|
+
/** An unbound/corrupt credential requires a deliberate login, never inference. */
|
|
85
|
+
export function requiresExplicitAuthLogin(issuer) {
|
|
86
|
+
if (loadAuth(issuer))
|
|
87
|
+
return false;
|
|
88
|
+
// Inspect existence only: the legacy credential is never read or migrated.
|
|
89
|
+
return existsSync(authFilePath(issuer)) || existsSync(join(authDir(), 'auth.json'));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Lower-level read — returns the whole struct so callers like `whoami`
|
|
93
|
+
* can display userId/email without re-parsing.
|
|
94
|
+
*/
|
|
95
|
+
export function loadAuth(issuer) {
|
|
96
|
+
const identity = normalizeCliIssuer(issuer);
|
|
97
|
+
try {
|
|
98
|
+
const path = authFilePath(identity);
|
|
99
|
+
if (!existsSync(path))
|
|
100
|
+
return null;
|
|
101
|
+
const raw = readFileSync(path, 'utf-8');
|
|
102
|
+
const parsed = JSON.parse(raw);
|
|
103
|
+
if (parsed.issuer !== identity ||
|
|
104
|
+
typeof parsed.token !== 'string' || parsed.token.length === 0 ||
|
|
105
|
+
typeof parsed.expiresAt !== 'number' || !Number.isFinite(parsed.expiresAt) ||
|
|
106
|
+
typeof parsed.userId !== 'string' || parsed.userId.length === 0) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
issuer: identity,
|
|
111
|
+
token: parsed.token,
|
|
112
|
+
expiresAt: parsed.expiresAt,
|
|
113
|
+
userId: parsed.userId,
|
|
114
|
+
email: typeof parsed.email === 'string' ? parsed.email : undefined,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Persist this issuer’s session atomically. Separate files keep concurrent
|
|
123
|
+
* logins to different environments from replacing each other. Old unbound
|
|
124
|
+
* auth.json files are never loaded; their owners must log in again.
|
|
125
|
+
*/
|
|
126
|
+
export function saveAuth(auth, options = {}) {
|
|
127
|
+
const issuer = normalizeCliIssuer(auth.issuer);
|
|
128
|
+
const dir = authDir();
|
|
129
|
+
if (!existsSync(dir)) {
|
|
130
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
131
|
+
}
|
|
132
|
+
chmodSync(dir, 0o700);
|
|
133
|
+
const destination = authFilePath(issuer);
|
|
134
|
+
const temporary = join(dir, `.auth.${process.pid}.${randomUUID()}.tmp`);
|
|
135
|
+
let handle;
|
|
136
|
+
try {
|
|
137
|
+
handle = openSync(temporary, 'wx', 0o600);
|
|
138
|
+
writeFileSync(handle, JSON.stringify({ ...auth, issuer }, null, 2), 'utf8');
|
|
139
|
+
fsyncSync(handle);
|
|
140
|
+
closeSync(handle);
|
|
141
|
+
handle = undefined;
|
|
142
|
+
if (options.faultAt === 'before-rename') {
|
|
143
|
+
throw new Error('AUTH_SAVE_FAULT_BEFORE_RENAME');
|
|
144
|
+
}
|
|
145
|
+
renameSync(temporary, destination);
|
|
146
|
+
chmodSync(destination, 0o600);
|
|
147
|
+
fsyncDirectoryBestEffort(dir);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (handle !== undefined)
|
|
151
|
+
closeSync(handle);
|
|
152
|
+
try {
|
|
153
|
+
if (existsSync(temporary))
|
|
154
|
+
unlinkSync(temporary);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Preserve the original write error; stale temp files carry no authority.
|
|
158
|
+
}
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Wipe the token file. Missing file is a no-op — `logout` should always
|
|
164
|
+
* succeed (same intent as `rm -f`).
|
|
165
|
+
*/
|
|
166
|
+
export async function clearAuthToken(issuer) {
|
|
167
|
+
const path = authFilePath(issuer);
|
|
168
|
+
try {
|
|
169
|
+
if (existsSync(path))
|
|
170
|
+
unlinkSync(path);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (error.code === 'ENOENT')
|
|
174
|
+
return;
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
export async function revokeAuthSession(options = {}) {
|
|
179
|
+
const { gatewayUrl } = resolveCliRuntimeConfig({
|
|
180
|
+
rootDir: options.rootDir,
|
|
181
|
+
gatewayUrl: options.gatewayUrl,
|
|
182
|
+
});
|
|
183
|
+
const stored = loadAuth(gatewayUrl);
|
|
184
|
+
if (!stored)
|
|
185
|
+
return 'not-authenticated';
|
|
186
|
+
const response = await fetchWithDeadline(cliIssuerRequestUrl(stored.issuer, '/api/cli/session/revoke'), {
|
|
187
|
+
method: 'POST',
|
|
188
|
+
redirect: 'error',
|
|
189
|
+
headers: {
|
|
190
|
+
Authorization: `Bearer ${stored.token}`,
|
|
191
|
+
'X-Storystick-Client': 'cli',
|
|
192
|
+
},
|
|
193
|
+
}, options);
|
|
194
|
+
if (!response.ok)
|
|
195
|
+
throw await storystickErrorFromResponse(response);
|
|
196
|
+
const receipt = await response.json().catch(() => null);
|
|
197
|
+
if (!receipt || typeof receipt !== 'object' || receipt.revoked !== true) {
|
|
198
|
+
throw new StorystickError('The gateway did not confirm CLI session revocation.', {
|
|
199
|
+
code: 'AUTH_REVOCATION_UNCONFIRMED', category: 'auth', retryable: false,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return 'revoked';
|
|
203
|
+
}
|
|
204
|
+
export async function logoutAuthSession(options = {}) {
|
|
205
|
+
const { gatewayUrl } = resolveCliRuntimeConfig(options);
|
|
206
|
+
if (options.localOnly === true) {
|
|
207
|
+
await clearAuthToken(gatewayUrl);
|
|
208
|
+
return 'local-only';
|
|
209
|
+
}
|
|
210
|
+
// Deliberate order: remote authority first. If that call fails, the local
|
|
211
|
+
// credential remains available so the user can retry revocation.
|
|
212
|
+
const result = await revokeAuthSession({ ...options, gatewayUrl });
|
|
213
|
+
await clearAuthToken(gatewayUrl);
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Return `{ userId, email }` from the local cache, or null when nothing is
|
|
218
|
+
* cached (or the cache has expired). Backs `storystick info` / `whoami`.
|
|
219
|
+
*/
|
|
220
|
+
export async function whoami(options = {}) {
|
|
221
|
+
const stored = loadAuth(resolveCliRuntimeConfig(options).gatewayUrl);
|
|
222
|
+
if (!stored)
|
|
223
|
+
return null;
|
|
224
|
+
if (stored.expiresAt <= Date.now())
|
|
225
|
+
return null;
|
|
226
|
+
return { userId: stored.userId, email: stored.email ?? null };
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Drive the full device-auth flow.
|
|
230
|
+
*
|
|
231
|
+
* On success: writes `auth.json`, returns the token.
|
|
232
|
+
* On failure: throws a human-readable `Error`; nothing written.
|
|
233
|
+
*
|
|
234
|
+
* The function does NOT print anything directly — printing is the CLI
|
|
235
|
+
* command's responsibility. This keeps `deviceAuthLogin` testable without
|
|
236
|
+
* piping stdout around.
|
|
237
|
+
*/
|
|
238
|
+
export async function deviceAuthLogin(options = {}) {
|
|
239
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
240
|
+
// Precedence: explicit override (CLI flag) > env var > built-in default.
|
|
241
|
+
// The env vars let devs point the whole flow at a local Hub without
|
|
242
|
+
// threading flags through every caller (`init`, `link`, `list`, etc.).
|
|
243
|
+
const { gatewayUrl, hubUrl } = resolveCliRuntimeConfig({
|
|
244
|
+
rootDir: options.rootDir,
|
|
245
|
+
gatewayUrl: options.gatewayUrl,
|
|
246
|
+
hubUrl: options.hubUrl,
|
|
247
|
+
});
|
|
248
|
+
if (gatewayUrl !== DEFAULT_GATEWAY_URL && options.gatewayUrl === undefined) {
|
|
249
|
+
throw new StorystickError(`Explicit login is required for gateway ${gatewayUrl}. Run storystick login --gateway-url with this origin.`, { code: 'AUTH_ISSUER_CONFIRMATION_REQUIRED', category: 'auth', retryable: false });
|
|
250
|
+
}
|
|
251
|
+
const pollInterval = options.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
252
|
+
// 1. Start the flow.
|
|
253
|
+
const start = await startDevice(fetchImpl, gatewayUrl, options.deviceName, {
|
|
254
|
+
signal: options.signal,
|
|
255
|
+
timeoutMs: options.timeoutMs,
|
|
256
|
+
});
|
|
257
|
+
// Replace the server-provided `verificationUrl` with one built from the
|
|
258
|
+
// configured `hubUrl` so dev environments (where Central's URL points at
|
|
259
|
+
// the convex.site deployment) still show the user a Hub subdomain they
|
|
260
|
+
// can click through. The `userCode` is authoritative on Central either way.
|
|
261
|
+
const displayUrl = buildDisplayUrl(hubUrl, start.userCode);
|
|
262
|
+
options.onPrompt?.({
|
|
263
|
+
issuer: gatewayUrl,
|
|
264
|
+
verificationUrl: displayUrl,
|
|
265
|
+
userCode: start.userCode,
|
|
266
|
+
expiresIn: start.expiresIn,
|
|
267
|
+
});
|
|
268
|
+
// 2. Poll until authorized / expired / aborted.
|
|
269
|
+
while (true) {
|
|
270
|
+
if (options.signal?.aborted) {
|
|
271
|
+
throw new Error('Login cancelled.');
|
|
272
|
+
}
|
|
273
|
+
await abortableSleep(pollInterval, options.signal);
|
|
274
|
+
options.onPoll?.();
|
|
275
|
+
const res = await pollDevice(fetchImpl, gatewayUrl, start.deviceCode, {
|
|
276
|
+
signal: options.signal,
|
|
277
|
+
timeoutMs: options.timeoutMs,
|
|
278
|
+
});
|
|
279
|
+
if (res.status === 'authorized') {
|
|
280
|
+
// 3. Decode the signed identity and cache the descriptive email returned
|
|
281
|
+
// by the same one-shot poll. Email is presentation metadata, not a JWT
|
|
282
|
+
// authorization claim.
|
|
283
|
+
const { userId } = decodeCliJwt(res.token);
|
|
284
|
+
saveAuth({
|
|
285
|
+
issuer: gatewayUrl,
|
|
286
|
+
token: res.token,
|
|
287
|
+
expiresAt: res.expiresAt,
|
|
288
|
+
userId: userId ?? 'unknown',
|
|
289
|
+
email: res.email ?? undefined,
|
|
290
|
+
});
|
|
291
|
+
return res.token;
|
|
292
|
+
}
|
|
293
|
+
if (res.status === 'expired') {
|
|
294
|
+
throw new Error('Device code expired before approval. Run `storystick login` again.');
|
|
295
|
+
}
|
|
296
|
+
if (res.status === 'not-found') {
|
|
297
|
+
// Shouldn't happen unless the DB was wiped mid-flow. Treat like expired.
|
|
298
|
+
throw new Error('Device code was not found on the server. Run `storystick login` again.');
|
|
299
|
+
}
|
|
300
|
+
// status === 'pending' → keep polling
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// ─────────────────────────────────────────────────────────────
|
|
304
|
+
// HTTP helpers
|
|
305
|
+
// ─────────────────────────────────────────────────────────────
|
|
306
|
+
async function startDevice(fetchImpl, gatewayUrl, deviceName, options) {
|
|
307
|
+
const body = {};
|
|
308
|
+
if (deviceName)
|
|
309
|
+
body.deviceName = deviceName;
|
|
310
|
+
const response = await fetchWithDeadline(`${gatewayUrl}/api/auth/device/start`, {
|
|
311
|
+
method: 'POST',
|
|
312
|
+
redirect: 'error',
|
|
313
|
+
headers: { 'Content-Type': 'application/json' },
|
|
314
|
+
body: JSON.stringify(body),
|
|
315
|
+
}, { fetchImpl, signal: options.signal, timeoutMs: options.timeoutMs });
|
|
316
|
+
if (!response.ok) {
|
|
317
|
+
const text = await response.text().catch(() => '');
|
|
318
|
+
throw new Error(`Could not start device login (HTTP ${response.status}): ${text.slice(0, 200)}`);
|
|
319
|
+
}
|
|
320
|
+
return (await response.json());
|
|
321
|
+
}
|
|
322
|
+
async function pollDevice(fetchImpl, gatewayUrl, deviceCode, options) {
|
|
323
|
+
const response = await fetchWithDeadline(`${gatewayUrl}/api/auth/device/poll`, {
|
|
324
|
+
method: 'POST',
|
|
325
|
+
redirect: 'error',
|
|
326
|
+
headers: { 'Content-Type': 'application/json' },
|
|
327
|
+
body: JSON.stringify({ deviceCode }),
|
|
328
|
+
}, { fetchImpl, signal: options.signal, timeoutMs: options.timeoutMs });
|
|
329
|
+
if (response.status === 429) {
|
|
330
|
+
// Rate-limited by the server — shouldn't happen at 3s cadence, but treat
|
|
331
|
+
// as a transient signal rather than a fatal error.
|
|
332
|
+
return { status: 'pending' };
|
|
333
|
+
}
|
|
334
|
+
if (!response.ok) {
|
|
335
|
+
const text = await response.text().catch(() => '');
|
|
336
|
+
throw new Error(`Poll failed (HTTP ${response.status}): ${text.slice(0, 200)}`);
|
|
337
|
+
}
|
|
338
|
+
return (await response.json());
|
|
339
|
+
}
|
|
340
|
+
// ─────────────────────────────────────────────────────────────
|
|
341
|
+
// Small helpers
|
|
342
|
+
// ─────────────────────────────────────────────────────────────
|
|
343
|
+
function buildDisplayUrl(hubUrl, userCode) {
|
|
344
|
+
// Use `?userCode=` (not `?code=`) because @convex-dev/auth/react reserves
|
|
345
|
+
// the `code` query param for its OAuth provider callback flow. A URL with
|
|
346
|
+
// `?code=` triggers Convex-Auth's signIn pipeline, strips the param, and
|
|
347
|
+
// bypasses the existing-session read — the user gets forced to sign in
|
|
348
|
+
// again even with a valid cookie. See ADR-041 + investigation 2026-04-22.
|
|
349
|
+
return `${hubUrl}/device?userCode=${encodeURIComponent(userCode)}`;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Decode a JWT payload WITHOUT verifying the signature — we trust the Hub
|
|
353
|
+
* here because we just received the token over HTTPS from it, and the data
|
|
354
|
+
* we pull out (`userId`) is non-security-sensitive cache metadata. A tampered
|
|
355
|
+
* token would fail verification on the next API call anyway.
|
|
356
|
+
*/
|
|
357
|
+
function decodeCliJwt(token) {
|
|
358
|
+
try {
|
|
359
|
+
const parts = token.split('.');
|
|
360
|
+
if (parts.length < 2)
|
|
361
|
+
return { userId: null };
|
|
362
|
+
const payloadJson = base64UrlDecode(parts[1]);
|
|
363
|
+
const payload = JSON.parse(payloadJson);
|
|
364
|
+
return {
|
|
365
|
+
userId: typeof payload.userId === 'string' ? payload.userId : null,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return { userId: null };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function base64UrlDecode(b64url) {
|
|
373
|
+
const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/').padEnd(b64url.length + ((4 - (b64url.length % 4)) % 4), '=');
|
|
374
|
+
return Buffer.from(b64, 'base64').toString('utf-8');
|
|
375
|
+
}
|
|
376
|
+
function fsyncDirectoryBestEffort(directory) {
|
|
377
|
+
let handle;
|
|
378
|
+
try {
|
|
379
|
+
handle = openSync(directory, 'r');
|
|
380
|
+
fsyncSync(handle);
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
// Some filesystems do not support directory fsync. The file itself has
|
|
384
|
+
// already been fsynced and atomically renamed.
|
|
385
|
+
}
|
|
386
|
+
finally {
|
|
387
|
+
if (handle !== undefined)
|
|
388
|
+
closeSync(handle);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
type BootstrapOriginMode = 'default-if-empty' | 'append';
|
|
2
|
+
export type CliBootstrapIntent = {
|
|
3
|
+
kind: 'create';
|
|
4
|
+
projectName: string;
|
|
5
|
+
teamShortId?: string;
|
|
6
|
+
newTeamName?: string;
|
|
7
|
+
initialLocale: string;
|
|
8
|
+
initialLocaleLabel: string;
|
|
9
|
+
initialAllowedOrigins: string[];
|
|
10
|
+
originMode: 'append';
|
|
11
|
+
} | {
|
|
12
|
+
kind: 'link';
|
|
13
|
+
requestedShortId: string;
|
|
14
|
+
initialAllowedOrigins: string[];
|
|
15
|
+
originMode: BootstrapOriginMode;
|
|
16
|
+
};
|
|
17
|
+
export interface CliBootstrapReceipt {
|
|
18
|
+
version: 1;
|
|
19
|
+
operationId: string;
|
|
20
|
+
kind: 'create' | 'link';
|
|
21
|
+
state: 'project_ready' | 'credentials_issued' | 'connection_acknowledged';
|
|
22
|
+
team?: {
|
|
23
|
+
shortId: string;
|
|
24
|
+
name: string;
|
|
25
|
+
};
|
|
26
|
+
projectShortId: string;
|
|
27
|
+
keyPairVersion: number;
|
|
28
|
+
allowedOrigins: string[];
|
|
29
|
+
createdAt: number;
|
|
30
|
+
updatedAt: number;
|
|
31
|
+
connectionAcknowledgedAt?: number;
|
|
32
|
+
}
|
|
33
|
+
export interface CliBootstrapOptions {
|
|
34
|
+
rootDir: string;
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
onAuthPrompt?: (data: {
|
|
37
|
+
verificationUrl: string;
|
|
38
|
+
userCode: string;
|
|
39
|
+
expiresIn: number;
|
|
40
|
+
}) => void;
|
|
41
|
+
}
|
|
42
|
+
export declare function prepareCliBootstrap(operationId: string, intent: CliBootstrapIntent, options: CliBootstrapOptions): Promise<{
|
|
43
|
+
receipt: CliBootstrapReceipt;
|
|
44
|
+
credentials?: {
|
|
45
|
+
apiKey: string;
|
|
46
|
+
publishableKey: string;
|
|
47
|
+
};
|
|
48
|
+
}>;
|
|
49
|
+
export declare function acknowledgeCliBootstrap(operationId: string, intent: CliBootstrapIntent, keyPairVersion: number, options: CliBootstrapOptions): Promise<{
|
|
50
|
+
receipt: CliBootstrapReceipt;
|
|
51
|
+
}>;
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { callHubApi } from './hub-client.js';
|
|
2
|
+
export async function prepareCliBootstrap(operationId, intent, options) {
|
|
3
|
+
return await callHubApi('/api/cli/bootstrap/prepare', {
|
|
4
|
+
method: 'POST',
|
|
5
|
+
body: { operationId, intent },
|
|
6
|
+
rootDir: options.rootDir,
|
|
7
|
+
signal: options.signal,
|
|
8
|
+
onAuthPrompt: options.onAuthPrompt,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export async function acknowledgeCliBootstrap(operationId, intent, keyPairVersion, options) {
|
|
12
|
+
return await callHubApi('/api/cli/bootstrap/ack', {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
body: { operationId, intent, keyPairVersion },
|
|
15
|
+
rootDir: options.rootDir,
|
|
16
|
+
signal: options.signal,
|
|
17
|
+
onAuthPrompt: options.onAuthPrompt,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-side error class. Single home for the error shape every CLI throw
|
|
3
|
+
* funnels into (ADR-072 §D3 + lock A6 — no re-export from hub-client).
|
|
4
|
+
*
|
|
5
|
+
* `cause` (lock L4) preserves the original error for downstream
|
|
6
|
+
* exit-code-2 detection in Batch 7 (e.g. a `TypeError` from `fetch` with
|
|
7
|
+
* `cause.code === 'ECONNREFUSED'` survives one wrapping into
|
|
8
|
+
* `StorystickError`).
|
|
9
|
+
*
|
|
10
|
+
* `docUrl` (lock F4) reserves the field shape for ADR-077; left
|
|
11
|
+
* undefined everywhere in v0.
|
|
12
|
+
*/
|
|
13
|
+
export declare class StorystickError extends Error {
|
|
14
|
+
/** Hub error code (e.g. `UNAUTHENTICATED`, `FORBIDDEN`, `HTTP_500`). */
|
|
15
|
+
readonly code?: string;
|
|
16
|
+
/** HTTP status when the error came from a Hub / Pattern D response. */
|
|
17
|
+
readonly status?: number;
|
|
18
|
+
readonly retryAfterMs?: number;
|
|
19
|
+
/** Structured server context for machine-readable recovery UI. */
|
|
20
|
+
readonly payload?: unknown;
|
|
21
|
+
/** Stable error category from the shared catalogue. */
|
|
22
|
+
readonly category?: string;
|
|
23
|
+
/** Whether retrying after user action can recover. */
|
|
24
|
+
readonly recoverable?: boolean;
|
|
25
|
+
/** Whether the same operation may be retried without user action. */
|
|
26
|
+
readonly retryable?: boolean;
|
|
27
|
+
/** Stable localization key and interpolation parameters. */
|
|
28
|
+
readonly messageKey?: string;
|
|
29
|
+
readonly params?: Record<string, unknown>;
|
|
30
|
+
/** Support correlation id returned by the gateway. */
|
|
31
|
+
readonly ref?: string;
|
|
32
|
+
/** Original error (e.g. fetch's TypeError with cause.code = 'ECONNREFUSED'). */
|
|
33
|
+
readonly cause?: unknown;
|
|
34
|
+
/** Documentation URL (reserved per F4 — undefined in v0). */
|
|
35
|
+
readonly docUrl?: string;
|
|
36
|
+
constructor(message: string, opts?: {
|
|
37
|
+
code?: string;
|
|
38
|
+
status?: number;
|
|
39
|
+
retryAfterMs?: number;
|
|
40
|
+
payload?: unknown;
|
|
41
|
+
category?: string;
|
|
42
|
+
recoverable?: boolean;
|
|
43
|
+
retryable?: boolean;
|
|
44
|
+
messageKey?: string;
|
|
45
|
+
params?: Record<string, unknown>;
|
|
46
|
+
ref?: string;
|
|
47
|
+
cause?: unknown;
|
|
48
|
+
docUrl?: string;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** Prefix or otherwise replace the message without dropping wire metadata. */
|
|
52
|
+
export declare function rewrapStorystickError(message: string, cause: unknown): StorystickError;
|
|
53
|
+
/** Parse both the canonical `{error:{...}}` envelope and the legacy flat form. */
|
|
54
|
+
export declare function storystickErrorFromResponse(response: Response, fallbackMessage?: string): Promise<StorystickError>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-side error class. Single home for the error shape every CLI throw
|
|
3
|
+
* funnels into (ADR-072 §D3 + lock A6 — no re-export from hub-client).
|
|
4
|
+
*
|
|
5
|
+
* `cause` (lock L4) preserves the original error for downstream
|
|
6
|
+
* exit-code-2 detection in Batch 7 (e.g. a `TypeError` from `fetch` with
|
|
7
|
+
* `cause.code === 'ECONNREFUSED'` survives one wrapping into
|
|
8
|
+
* `StorystickError`).
|
|
9
|
+
*
|
|
10
|
+
* `docUrl` (lock F4) reserves the field shape for ADR-077; left
|
|
11
|
+
* undefined everywhere in v0.
|
|
12
|
+
*/
|
|
13
|
+
export class StorystickError extends Error {
|
|
14
|
+
/** Hub error code (e.g. `UNAUTHENTICATED`, `FORBIDDEN`, `HTTP_500`). */
|
|
15
|
+
code;
|
|
16
|
+
/** HTTP status when the error came from a Hub / Pattern D response. */
|
|
17
|
+
status;
|
|
18
|
+
retryAfterMs;
|
|
19
|
+
/** Structured server context for machine-readable recovery UI. */
|
|
20
|
+
payload;
|
|
21
|
+
/** Stable error category from the shared catalogue. */
|
|
22
|
+
category;
|
|
23
|
+
/** Whether retrying after user action can recover. */
|
|
24
|
+
recoverable;
|
|
25
|
+
/** Whether the same operation may be retried without user action. */
|
|
26
|
+
retryable;
|
|
27
|
+
/** Stable localization key and interpolation parameters. */
|
|
28
|
+
messageKey;
|
|
29
|
+
params;
|
|
30
|
+
/** Support correlation id returned by the gateway. */
|
|
31
|
+
ref;
|
|
32
|
+
/** Original error (e.g. fetch's TypeError with cause.code = 'ECONNREFUSED'). */
|
|
33
|
+
cause;
|
|
34
|
+
/** Documentation URL (reserved per F4 — undefined in v0). */
|
|
35
|
+
docUrl;
|
|
36
|
+
constructor(message, opts = {}) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = 'StorystickError';
|
|
39
|
+
this.code = opts.code;
|
|
40
|
+
this.status = opts.status;
|
|
41
|
+
this.retryAfterMs = opts.retryAfterMs;
|
|
42
|
+
this.payload = opts.payload;
|
|
43
|
+
this.category = opts.category;
|
|
44
|
+
this.recoverable = opts.recoverable;
|
|
45
|
+
this.retryable = opts.retryable;
|
|
46
|
+
this.messageKey = opts.messageKey;
|
|
47
|
+
this.params = opts.params;
|
|
48
|
+
this.ref = opts.ref;
|
|
49
|
+
this.cause = opts.cause;
|
|
50
|
+
this.docUrl = opts.docUrl;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Prefix or otherwise replace the message without dropping wire metadata. */
|
|
54
|
+
export function rewrapStorystickError(message, cause) {
|
|
55
|
+
const source = cause instanceof StorystickError ? cause : undefined;
|
|
56
|
+
return new StorystickError(message, {
|
|
57
|
+
code: source?.code,
|
|
58
|
+
status: source?.status,
|
|
59
|
+
retryAfterMs: source?.retryAfterMs,
|
|
60
|
+
payload: source?.payload,
|
|
61
|
+
category: source?.category,
|
|
62
|
+
recoverable: source?.recoverable,
|
|
63
|
+
retryable: source?.retryable,
|
|
64
|
+
messageKey: source?.messageKey,
|
|
65
|
+
params: source?.params,
|
|
66
|
+
ref: source?.ref,
|
|
67
|
+
docUrl: source?.docUrl,
|
|
68
|
+
cause,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/** Parse both the canonical `{error:{...}}` envelope and the legacy flat form. */
|
|
72
|
+
export async function storystickErrorFromResponse(response, fallbackMessage = response.statusText || 'Request failed') {
|
|
73
|
+
const raw = await response.text().catch(() => '');
|
|
74
|
+
let body = {};
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(raw);
|
|
77
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
78
|
+
body = parsed;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Non-JSON response: use HTTP fallbacks below.
|
|
82
|
+
}
|
|
83
|
+
const details = body.error && typeof body.error === 'object' ? body.error : body;
|
|
84
|
+
const retryAfter = response.headers.get('Retry-After');
|
|
85
|
+
const delay = retryAfter === null ? NaN : /^\d+(\.\d+)?$/.test(retryAfter) ? Number(retryAfter) * 1000 : Date.parse(retryAfter) - Date.now();
|
|
86
|
+
return new StorystickError(typeof details.message === 'string' ? details.message : typeof body.error === 'string' ? body.error
|
|
87
|
+
: raw.trim() ? `${fallbackMessage} — ${raw.slice(0, 200).trim()}` : fallbackMessage, {
|
|
88
|
+
code: typeof details.code === 'string'
|
|
89
|
+
? details.code
|
|
90
|
+
: `HTTP_${response.status}`,
|
|
91
|
+
status: response.status,
|
|
92
|
+
retryAfterMs: Number.isFinite(delay) ? Math.max(0, delay) : undefined,
|
|
93
|
+
payload: details.payload,
|
|
94
|
+
category: typeof details.category === 'string' ? details.category : undefined,
|
|
95
|
+
recoverable: typeof details.recoverable === 'boolean'
|
|
96
|
+
? details.recoverable
|
|
97
|
+
: undefined,
|
|
98
|
+
retryable: typeof details.retryable === 'boolean' ? details.retryable : undefined,
|
|
99
|
+
messageKey: typeof details.messageKey === 'string' ? details.messageKey : undefined,
|
|
100
|
+
params: details.params &&
|
|
101
|
+
typeof details.params === 'object' &&
|
|
102
|
+
!Array.isArray(details.params)
|
|
103
|
+
? details.params
|
|
104
|
+
: undefined,
|
|
105
|
+
ref: typeof details.ref === 'string' ? details.ref : undefined,
|
|
106
|
+
});
|
|
107
|
+
}
|