@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,18 @@
|
|
|
1
|
+
export type TeamRole = 'owner' | 'admin' | 'member';
|
|
2
|
+
export type ContentRole = 'editor' | 'viewer';
|
|
3
|
+
export type ProjectAccessSource = 'team' | 'direct' | 'override';
|
|
4
|
+
export interface ProjectCapabilities {
|
|
5
|
+
readContent: boolean;
|
|
6
|
+
writeContent: boolean;
|
|
7
|
+
configureContent: boolean;
|
|
8
|
+
configureProject: boolean;
|
|
9
|
+
manageProjectAccess: boolean;
|
|
10
|
+
manageProjectCredentials: boolean;
|
|
11
|
+
archiveProject: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface ProjectAccessSummary {
|
|
14
|
+
teamRole: TeamRole | null;
|
|
15
|
+
contentRole: ContentRole;
|
|
16
|
+
accessSource: ProjectAccessSource;
|
|
17
|
+
capabilities: ProjectCapabilities;
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export declare const PROJECT_CONNECTION_ENV_KEYS: readonly ["STORYSTICK_PROJECT_ID", "NEXT_PUBLIC_STORYSTICK_PROJECT_ID", "STORYSTICK_API_KEY", "NEXT_PUBLIC_STORYSTICK_PUBLISHABLE_KEY"];
|
|
2
|
+
export type ProjectConnectionEnvKey = (typeof PROJECT_CONNECTION_ENV_KEYS)[number];
|
|
3
|
+
export interface ProjectConnectionValues {
|
|
4
|
+
projectId: string;
|
|
5
|
+
publicProjectId: string;
|
|
6
|
+
apiKey: string;
|
|
7
|
+
publishableKey: string;
|
|
8
|
+
}
|
|
9
|
+
/** Validated connection: both ids and both key prefixes already agree. */
|
|
10
|
+
export interface ProjectConnection {
|
|
11
|
+
shortId: string;
|
|
12
|
+
apiKey: string;
|
|
13
|
+
publishableKey: string;
|
|
14
|
+
keyEnvironment: 'live' | 'test';
|
|
15
|
+
}
|
|
16
|
+
export interface LocatedProjectConnection extends ProjectConnection {
|
|
17
|
+
source: 'environment' | 'env-file';
|
|
18
|
+
envFile: string | null;
|
|
19
|
+
}
|
|
20
|
+
export interface ProjectConnectionChange {
|
|
21
|
+
name: ProjectConnectionEnvKey;
|
|
22
|
+
action: 'set' | 'replace' | 'unchanged';
|
|
23
|
+
/** Safe for human and JSON output. Secret values are always redacted. */
|
|
24
|
+
value: string;
|
|
25
|
+
}
|
|
26
|
+
export interface ProjectConnectionWriteResult {
|
|
27
|
+
envFile: string;
|
|
28
|
+
created: boolean;
|
|
29
|
+
changed: boolean;
|
|
30
|
+
dryRun: boolean;
|
|
31
|
+
changes: ProjectConnectionChange[];
|
|
32
|
+
}
|
|
33
|
+
export declare function parseProjectConnection(values: ProjectConnectionValues): ProjectConnection;
|
|
34
|
+
export declare function projectConnectionValues(connection: ProjectConnection): Record<ProjectConnectionEnvKey, string>;
|
|
35
|
+
/**
|
|
36
|
+
* Resolve one complete authority. If any process-level connection variable is
|
|
37
|
+
* present, all four must be present and coherent; values are never mixed with
|
|
38
|
+
* a file. Otherwise the nearest `.env.local` containing any connection field
|
|
39
|
+
* is parsed as one indivisible unit.
|
|
40
|
+
*/
|
|
41
|
+
export declare function resolveProjectConnection(startDir?: string, env?: NodeJS.ProcessEnv): LocatedProjectConnection | null;
|
|
42
|
+
export declare function requireProjectConnection(startDir?: string, env?: NodeJS.ProcessEnv): LocatedProjectConnection;
|
|
43
|
+
/** Find the nearest `.env.local` containing at least one connection field. */
|
|
44
|
+
export declare function findProjectConnectionEnvFile(startDir?: string): string | null;
|
|
45
|
+
/**
|
|
46
|
+
* Commit the four values through one temp-file + fsync + rename transaction.
|
|
47
|
+
* The returned plan is safe to serialize; it never contains either key.
|
|
48
|
+
*/
|
|
49
|
+
export declare function commitProjectConnection(envFile: string, connection: ProjectConnection, options?: {
|
|
50
|
+
dryRun?: boolean;
|
|
51
|
+
faultAt?: 'before-rename';
|
|
52
|
+
}): ProjectConnectionWriteResult;
|
|
53
|
+
/** Safe dry-run plan for connect before any credential has been minted. */
|
|
54
|
+
export declare function planProjectConnection(envFile: string, shortId: string): ProjectConnectionWriteResult;
|
|
55
|
+
/** Validate the JWT returned by the secret-key exchange before local commit. */
|
|
56
|
+
export declare function validateProjectTokenReadback(token: string, connection: ProjectConnection): void;
|
|
57
|
+
export declare function redactProjectSecrets(text: string): string;
|
|
58
|
+
export declare function parseProjectShortId(value: string, field?: string): string;
|
|
59
|
+
export declare function parseEnvAssignment(line: string): {
|
|
60
|
+
key: string;
|
|
61
|
+
value: string;
|
|
62
|
+
} | null;
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { StorystickError } from './cli-error.js';
|
|
5
|
+
export const PROJECT_CONNECTION_ENV_KEYS = [
|
|
6
|
+
'STORYSTICK_PROJECT_ID',
|
|
7
|
+
'NEXT_PUBLIC_STORYSTICK_PROJECT_ID',
|
|
8
|
+
'STORYSTICK_API_KEY',
|
|
9
|
+
'NEXT_PUBLIC_STORYSTICK_PUBLISHABLE_KEY',
|
|
10
|
+
];
|
|
11
|
+
const SHORT_ID_PATTERN = /^[0-9a-hjkmnp-tv-z]{8}$/;
|
|
12
|
+
const API_KEY_PATTERN = /^ss_(sk|pk)_(live|test)_([0-9a-hjkmnp-tv-z]{8})_([A-Za-z0-9]{20,})$/;
|
|
13
|
+
const SECRET_PATTERN = /ss_(?:sk|pk)_(?:live|test)_[0-9a-hjkmnp-tv-z]{8}_[A-Za-z0-9]{20,}/g;
|
|
14
|
+
const REDACTED = '[redacted]';
|
|
15
|
+
export function parseProjectConnection(values) {
|
|
16
|
+
const projectId = parseProjectShortId(values.projectId, 'STORYSTICK_PROJECT_ID');
|
|
17
|
+
const publicProjectId = parseProjectShortId(values.publicProjectId, 'NEXT_PUBLIC_STORYSTICK_PROJECT_ID');
|
|
18
|
+
if (projectId !== publicProjectId) {
|
|
19
|
+
throw connectionError('STORYSTICK_PROJECT_ID and NEXT_PUBLIC_STORYSTICK_PROJECT_ID must match.', 'PROJECT_CONNECTION_ID_MISMATCH');
|
|
20
|
+
}
|
|
21
|
+
const secret = parseApiKey(values.apiKey, 'secret');
|
|
22
|
+
const publishable = parseApiKey(values.publishableKey, 'publishable');
|
|
23
|
+
if (secret.shortId !== projectId || publishable.shortId !== projectId) {
|
|
24
|
+
throw connectionError('Both Storystick keys must belong to the configured project shortId.', 'PROJECT_CONNECTION_KEY_MISMATCH');
|
|
25
|
+
}
|
|
26
|
+
if (secret.environment !== publishable.environment) {
|
|
27
|
+
throw connectionError('The secret and publishable Storystick keys must use the same environment.', 'PROJECT_CONNECTION_ENV_MISMATCH');
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
shortId: projectId,
|
|
31
|
+
apiKey: values.apiKey,
|
|
32
|
+
publishableKey: values.publishableKey,
|
|
33
|
+
keyEnvironment: secret.environment,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function projectConnectionValues(connection) {
|
|
37
|
+
return {
|
|
38
|
+
STORYSTICK_PROJECT_ID: connection.shortId,
|
|
39
|
+
NEXT_PUBLIC_STORYSTICK_PROJECT_ID: connection.shortId,
|
|
40
|
+
STORYSTICK_API_KEY: connection.apiKey,
|
|
41
|
+
NEXT_PUBLIC_STORYSTICK_PUBLISHABLE_KEY: connection.publishableKey,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolve one complete authority. If any process-level connection variable is
|
|
46
|
+
* present, all four must be present and coherent; values are never mixed with
|
|
47
|
+
* a file. Otherwise the nearest `.env.local` containing any connection field
|
|
48
|
+
* is parsed as one indivisible unit.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveProjectConnection(startDir = process.cwd(), env = process.env) {
|
|
51
|
+
const environment = connectionValuesFromEnvironment(env);
|
|
52
|
+
if (environment.present) {
|
|
53
|
+
return {
|
|
54
|
+
...parseProjectConnection(environment.values),
|
|
55
|
+
source: 'environment',
|
|
56
|
+
envFile: null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const envFile = findProjectConnectionEnvFile(startDir);
|
|
60
|
+
if (!envFile)
|
|
61
|
+
return null;
|
|
62
|
+
const parsed = connectionValuesFromText(readFileSync(envFile, 'utf8'), envFile);
|
|
63
|
+
return {
|
|
64
|
+
...parseProjectConnection(parsed),
|
|
65
|
+
source: 'env-file',
|
|
66
|
+
envFile,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function requireProjectConnection(startDir = process.cwd(), env = process.env) {
|
|
70
|
+
const connection = resolveProjectConnection(startDir, env);
|
|
71
|
+
if (connection)
|
|
72
|
+
return connection;
|
|
73
|
+
throw connectionError(`No ProjectConnection found from ${resolve(startDir)}. Run \`storystick connect <shortId> --root ${resolve(startDir)}\`.`, 'PROJECT_CONNECTION_MISSING');
|
|
74
|
+
}
|
|
75
|
+
/** Find the nearest `.env.local` containing at least one connection field. */
|
|
76
|
+
export function findProjectConnectionEnvFile(startDir = process.cwd()) {
|
|
77
|
+
let directory = resolve(startDir);
|
|
78
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
79
|
+
const envFile = join(directory, '.env.local');
|
|
80
|
+
if (existsSync(envFile) &&
|
|
81
|
+
containsProjectConnectionField(readFileSync(envFile, 'utf8'))) {
|
|
82
|
+
return envFile;
|
|
83
|
+
}
|
|
84
|
+
const parent = dirname(directory);
|
|
85
|
+
if (parent === directory)
|
|
86
|
+
return null;
|
|
87
|
+
directory = parent;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Commit the four values through one temp-file + fsync + rename transaction.
|
|
93
|
+
* The returned plan is safe to serialize; it never contains either key.
|
|
94
|
+
*/
|
|
95
|
+
export function commitProjectConnection(envFile, connection, options = {}) {
|
|
96
|
+
// Revalidate at the write boundary so callers cannot fabricate the branded
|
|
97
|
+
// shape with a type assertion and bypass the invariant.
|
|
98
|
+
const valid = parseProjectConnection({
|
|
99
|
+
projectId: connection.shortId,
|
|
100
|
+
publicProjectId: connection.shortId,
|
|
101
|
+
apiKey: connection.apiKey,
|
|
102
|
+
publishableKey: connection.publishableKey,
|
|
103
|
+
});
|
|
104
|
+
const destination = resolve(envFile);
|
|
105
|
+
const existed = existsSync(destination);
|
|
106
|
+
const before = existed ? readFileSync(destination, 'utf8') : '';
|
|
107
|
+
const desired = projectConnectionValues(valid);
|
|
108
|
+
const after = replaceProjectConnectionBlock(before, desired);
|
|
109
|
+
const result = {
|
|
110
|
+
envFile: destination,
|
|
111
|
+
created: !existed,
|
|
112
|
+
changed: before !== after,
|
|
113
|
+
dryRun: options.dryRun === true,
|
|
114
|
+
changes: safeChanges(before, desired),
|
|
115
|
+
};
|
|
116
|
+
if (options.dryRun === true || before === after)
|
|
117
|
+
return result;
|
|
118
|
+
const directory = dirname(destination);
|
|
119
|
+
const temporary = join(directory, `.${basename(destination)}.${process.pid}.${randomUUID()}.tmp`);
|
|
120
|
+
let handle;
|
|
121
|
+
try {
|
|
122
|
+
handle = openSync(temporary, 'wx', 0o600);
|
|
123
|
+
writeFileSync(handle, after, 'utf8');
|
|
124
|
+
fsyncSync(handle);
|
|
125
|
+
closeSync(handle);
|
|
126
|
+
handle = undefined;
|
|
127
|
+
if (options.faultAt === 'before-rename') {
|
|
128
|
+
throw new Error('PROJECT_CONNECTION_FAULT_BEFORE_RENAME');
|
|
129
|
+
}
|
|
130
|
+
renameSync(temporary, destination);
|
|
131
|
+
chmodSync(destination, 0o600);
|
|
132
|
+
fsyncDirectoryBestEffort(directory);
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (handle !== undefined)
|
|
137
|
+
closeSync(handle);
|
|
138
|
+
try {
|
|
139
|
+
if (existsSync(temporary))
|
|
140
|
+
unlinkSync(temporary);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Preserve the original transaction failure.
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** Safe dry-run plan for connect before any credential has been minted. */
|
|
149
|
+
export function planProjectConnection(envFile, shortId) {
|
|
150
|
+
const normalized = parseProjectShortId(shortId, 'project shortId');
|
|
151
|
+
const destination = resolve(envFile);
|
|
152
|
+
const existed = existsSync(destination);
|
|
153
|
+
const before = existed ? readFileSync(destination, 'utf8') : '';
|
|
154
|
+
const old = firstConnectionValues(before);
|
|
155
|
+
return {
|
|
156
|
+
envFile: destination,
|
|
157
|
+
created: !existed,
|
|
158
|
+
changed: true,
|
|
159
|
+
dryRun: true,
|
|
160
|
+
changes: PROJECT_CONNECTION_ENV_KEYS.map((name) => ({
|
|
161
|
+
name,
|
|
162
|
+
action: old.has(name) ? 'replace' : 'set',
|
|
163
|
+
value: isSecretKey(name) ? REDACTED : normalized,
|
|
164
|
+
})),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** Validate the JWT returned by the secret-key exchange before local commit. */
|
|
168
|
+
export function validateProjectTokenReadback(token, connection) {
|
|
169
|
+
let payload;
|
|
170
|
+
try {
|
|
171
|
+
const part = token.split('.')[1];
|
|
172
|
+
if (!part)
|
|
173
|
+
throw new Error('missing payload');
|
|
174
|
+
payload = JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
throw connectionError('Gateway returned an unreadable project token.', 'PROJECT_TOKEN_READBACK_INVALID');
|
|
178
|
+
}
|
|
179
|
+
const expectedEnv = connection.keyEnvironment === 'live' ? 'prod' : 'preview';
|
|
180
|
+
if (payload.projectId !== connection.shortId ||
|
|
181
|
+
payload.scope !== 'full' ||
|
|
182
|
+
payload.env !== expectedEnv) {
|
|
183
|
+
throw connectionError('Gateway token does not match the proposed ProjectConnection.', 'PROJECT_TOKEN_READBACK_MISMATCH');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
export function redactProjectSecrets(text) {
|
|
187
|
+
return text.replace(SECRET_PATTERN, REDACTED);
|
|
188
|
+
}
|
|
189
|
+
function parseApiKey(value, expectedType) {
|
|
190
|
+
const match = API_KEY_PATTERN.exec(value);
|
|
191
|
+
const expectedCode = expectedType === 'secret' ? 'sk' : 'pk';
|
|
192
|
+
if (!match || match[1] !== expectedCode) {
|
|
193
|
+
const variable = expectedType === 'secret'
|
|
194
|
+
? 'STORYSTICK_API_KEY'
|
|
195
|
+
: 'NEXT_PUBLIC_STORYSTICK_PUBLISHABLE_KEY';
|
|
196
|
+
throw connectionError(`${variable} is not a valid ${expectedType} Storystick key.`, 'PROJECT_CONNECTION_KEY_INVALID');
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
environment: match[2],
|
|
200
|
+
shortId: match[3],
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
export function parseProjectShortId(value, field = 'project shortId') {
|
|
204
|
+
const normalized = value.trim().toLowerCase();
|
|
205
|
+
if (!SHORT_ID_PATTERN.test(normalized)) {
|
|
206
|
+
throw connectionError(`${field} must be an 8-character Storystick project shortId.`, 'PROJECT_CONNECTION_ID_INVALID');
|
|
207
|
+
}
|
|
208
|
+
return normalized;
|
|
209
|
+
}
|
|
210
|
+
function connectionValuesFromEnvironment(env) {
|
|
211
|
+
const present = PROJECT_CONNECTION_ENV_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(env, key));
|
|
212
|
+
if (present.length === 0) {
|
|
213
|
+
return {
|
|
214
|
+
present: false,
|
|
215
|
+
values: { projectId: '', publicProjectId: '', apiKey: '', publishableKey: '' },
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
if (present.length !== PROJECT_CONNECTION_ENV_KEYS.length) {
|
|
219
|
+
throw connectionError('Process environment contains a partial ProjectConnection; provide all four variables or none.', 'PROJECT_CONNECTION_PARTIAL');
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
present: true,
|
|
223
|
+
values: {
|
|
224
|
+
projectId: env.STORYSTICK_PROJECT_ID ?? '',
|
|
225
|
+
publicProjectId: env.NEXT_PUBLIC_STORYSTICK_PROJECT_ID ?? '',
|
|
226
|
+
apiKey: env.STORYSTICK_API_KEY ?? '',
|
|
227
|
+
publishableKey: env.NEXT_PUBLIC_STORYSTICK_PUBLISHABLE_KEY ?? '',
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
function connectionValuesFromText(content, source) {
|
|
232
|
+
const values = new Map();
|
|
233
|
+
for (const line of content.split(/\r?\n/)) {
|
|
234
|
+
const parsed = parseEnvAssignment(line);
|
|
235
|
+
if (!parsed || !isConnectionKey(parsed.key))
|
|
236
|
+
continue;
|
|
237
|
+
if (values.has(parsed.key)) {
|
|
238
|
+
throw connectionError(`${source} contains duplicate ${parsed.key} entries.`, 'PROJECT_CONNECTION_DUPLICATE');
|
|
239
|
+
}
|
|
240
|
+
values.set(parsed.key, parsed.value);
|
|
241
|
+
}
|
|
242
|
+
const missing = PROJECT_CONNECTION_ENV_KEYS.filter((key) => !values.has(key));
|
|
243
|
+
if (missing.length > 0) {
|
|
244
|
+
throw connectionError(`${source} contains a partial ProjectConnection; missing ${missing.join(', ')}.`, 'PROJECT_CONNECTION_PARTIAL');
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
projectId: values.get('STORYSTICK_PROJECT_ID'),
|
|
248
|
+
publicProjectId: values.get('NEXT_PUBLIC_STORYSTICK_PROJECT_ID'),
|
|
249
|
+
apiKey: values.get('STORYSTICK_API_KEY'),
|
|
250
|
+
publishableKey: values.get('NEXT_PUBLIC_STORYSTICK_PUBLISHABLE_KEY'),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function replaceProjectConnectionBlock(content, desired) {
|
|
254
|
+
const newline = content.includes('\r\n') ? '\r\n' : '\n';
|
|
255
|
+
const body = content.replace(/(?:\r?\n)$/, '');
|
|
256
|
+
const lines = body.length > 0 ? body.split(/\r?\n/) : [];
|
|
257
|
+
const connectionLines = PROJECT_CONNECTION_ENV_KEYS.map((key) => `${key}=${desired[key]}`);
|
|
258
|
+
const next = [];
|
|
259
|
+
let inserted = false;
|
|
260
|
+
for (const line of lines) {
|
|
261
|
+
const parsed = parseEnvAssignment(line);
|
|
262
|
+
if (parsed && isConnectionKey(parsed.key)) {
|
|
263
|
+
if (!inserted) {
|
|
264
|
+
next.push(...connectionLines);
|
|
265
|
+
inserted = true;
|
|
266
|
+
}
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
next.push(line);
|
|
270
|
+
}
|
|
271
|
+
if (!inserted)
|
|
272
|
+
next.push(...connectionLines);
|
|
273
|
+
return next.join(newline) + newline;
|
|
274
|
+
}
|
|
275
|
+
function safeChanges(before, desired) {
|
|
276
|
+
const old = firstConnectionValues(before);
|
|
277
|
+
return PROJECT_CONNECTION_ENV_KEYS.map((name) => ({
|
|
278
|
+
name,
|
|
279
|
+
action: !old.has(name)
|
|
280
|
+
? 'set'
|
|
281
|
+
: old.get(name) === desired[name]
|
|
282
|
+
? 'unchanged'
|
|
283
|
+
: 'replace',
|
|
284
|
+
value: isSecretKey(name) ? REDACTED : desired[name],
|
|
285
|
+
}));
|
|
286
|
+
}
|
|
287
|
+
function firstConnectionValues(content) {
|
|
288
|
+
const values = new Map();
|
|
289
|
+
for (const line of content.split(/\r?\n/)) {
|
|
290
|
+
const parsed = parseEnvAssignment(line);
|
|
291
|
+
if (parsed && isConnectionKey(parsed.key) && !values.has(parsed.key)) {
|
|
292
|
+
values.set(parsed.key, parsed.value);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return values;
|
|
296
|
+
}
|
|
297
|
+
function containsProjectConnectionField(content) {
|
|
298
|
+
return content.split(/\r?\n/).some((line) => {
|
|
299
|
+
const parsed = parseEnvAssignment(line);
|
|
300
|
+
return parsed !== null && isConnectionKey(parsed.key);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
export function parseEnvAssignment(line) {
|
|
304
|
+
const trimmed = line.trim();
|
|
305
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
306
|
+
return null;
|
|
307
|
+
const equals = trimmed.indexOf('=');
|
|
308
|
+
if (equals < 1)
|
|
309
|
+
return null;
|
|
310
|
+
const key = trimmed.slice(0, equals).trim();
|
|
311
|
+
let value = trimmed.slice(equals + 1).trim();
|
|
312
|
+
if (value.length >= 2 &&
|
|
313
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
314
|
+
(value.startsWith("'") && value.endsWith("'")))) {
|
|
315
|
+
value = value.slice(1, -1);
|
|
316
|
+
}
|
|
317
|
+
return { key, value };
|
|
318
|
+
}
|
|
319
|
+
function isConnectionKey(key) {
|
|
320
|
+
return PROJECT_CONNECTION_ENV_KEYS.includes(key);
|
|
321
|
+
}
|
|
322
|
+
function isSecretKey(key) {
|
|
323
|
+
return (key === 'STORYSTICK_API_KEY' ||
|
|
324
|
+
key === 'NEXT_PUBLIC_STORYSTICK_PUBLISHABLE_KEY');
|
|
325
|
+
}
|
|
326
|
+
function connectionError(message, code) {
|
|
327
|
+
return new StorystickError(message, {
|
|
328
|
+
code,
|
|
329
|
+
category: 'validation',
|
|
330
|
+
recoverable: true,
|
|
331
|
+
retryable: false,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
function fsyncDirectoryBestEffort(directory) {
|
|
335
|
+
let handle;
|
|
336
|
+
try {
|
|
337
|
+
handle = openSync(directory, 'r');
|
|
338
|
+
fsyncSync(handle);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
// File data is already fsynced and rename is atomic on supported targets.
|
|
342
|
+
}
|
|
343
|
+
finally {
|
|
344
|
+
if (handle !== undefined)
|
|
345
|
+
closeSync(handle);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { type HubApiOptions } from './hub-client.js';
|
|
2
|
+
export declare function executeProjectCapability(projectShortId: string, request: Record<string, unknown>, options?: Pick<HubApiOptions, 'gatewayUrl' | 'rootDir' | 'fetchImpl' | 'signal'>): Promise<Record<string, unknown>>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { callHubApi } from './hub-client.js';
|
|
2
|
+
import { resolveCliRuntimeConfig } from './runtime-config.js';
|
|
3
|
+
import { resolveReleaseContext } from './release-context.js';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
export async function executeProjectCapability(projectShortId, request, options = {}) {
|
|
6
|
+
// Observe the same selection as push/sync. It is client context, never an
|
|
7
|
+
// implicit replacement for a journal's server-owned editorial scopes.
|
|
8
|
+
let cliReleaseContext;
|
|
9
|
+
if (request.operation === 'begin' || request.operation === 'status') {
|
|
10
|
+
const rootDir = resolve(options.rootDir ?? '.');
|
|
11
|
+
const { gatewayUrl } = resolveCliRuntimeConfig({ rootDir, gatewayUrl: options.gatewayUrl });
|
|
12
|
+
try {
|
|
13
|
+
const selected = resolveReleaseContext(rootDir, gatewayUrl);
|
|
14
|
+
cliReleaseContext = selected && selected.projectShortId !== projectShortId
|
|
15
|
+
? { projectShortId, errorCode: 'CLI_RELEASE_CONTEXT_PROJECT_MISMATCH' }
|
|
16
|
+
: { projectShortId, releaseId: selected?.releaseId ?? null };
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
cliReleaseContext = { projectShortId, errorCode: 'CLI_RELEASE_CONTEXT_INVALID' };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// A cold workspace installation can outlive the ordinary content request deadline.
|
|
23
|
+
// Host cancellation is still forwarded; the server owns the hard VM lifetime.
|
|
24
|
+
const input = request.input;
|
|
25
|
+
const runtimeCall = request.operation === 'call' && request.toolName === 'projectCode' &&
|
|
26
|
+
(input?.operation === 'check' || input?.operation === 'preview' || input?.operation === 'exec');
|
|
27
|
+
const result = await callHubApi('/api/cli/project-execution', {
|
|
28
|
+
...options, method: 'POST', body: { projectShortId, request }, interactiveAuth: false, timeoutMs: runtimeCall ? 610_000 : 60_000,
|
|
29
|
+
});
|
|
30
|
+
return cliReleaseContext && result.context && typeof result.context === 'object'
|
|
31
|
+
? { ...result, context: { ...result.context, cliReleaseContext } }
|
|
32
|
+
: result;
|
|
33
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern D schema push — plain `fetch()` client used by the CLI.
|
|
3
|
+
*
|
|
4
|
+
* Post ADR-058 + audit FIX-1 (Pattern D bridge): `schemas.push` no longer
|
|
5
|
+
* exists at Central's top-level Convex API. It lives inside the backend
|
|
6
|
+
* Component and is only reachable via the Pattern D mutation endpoint
|
|
7
|
+
* `POST <gatewayUrl>/api/m/schemas/push`. This module hides the
|
|
8
|
+
* endpoint shape behind a single function used by both `storystick push`
|
|
9
|
+
* / `sync` (cli.ts) and `storystick init` (init.ts) — one wire format,
|
|
10
|
+
* one place to maintain it.
|
|
11
|
+
*
|
|
12
|
+
* We avoid `ConvexHttpClient` on purpose: the Pattern D contract is plain
|
|
13
|
+
* HTTP so the edge Worker (follow-up PR) can cache+rewrite without
|
|
14
|
+
* understanding Convex's protocol, and so the SDK has zero runtime
|
|
15
|
+
* Convex dependency.
|
|
16
|
+
*
|
|
17
|
+
* ADR-072 §D3: failures throw `StorystickError` with `code` + `status` so
|
|
18
|
+
* the CLI's exit-code router (Batch 7) can route HTTP 5xx and network
|
|
19
|
+
* errors uniformly. Network errors preserve their original cause via
|
|
20
|
+
* `cause` (lock L4) so `TypeError(cause: ECONNREFUSED)` survives wrapping.
|
|
21
|
+
*/
|
|
22
|
+
import { STORY_SCHEMA_FORMAT_VERSION, type SchemaChangePlanV1, type SerializedSchema } from '@storystick/types';
|
|
23
|
+
/**
|
|
24
|
+
* Body of `POST /api/m/schemas/push`. Mirrors the args of the backend
|
|
25
|
+
* Component's `schemas.push` mutation. Kept as a structural type (not
|
|
26
|
+
* imported from the backend) so this module doesn't depend on Convex
|
|
27
|
+
* code — the CLI must stay buildable in environments without Convex.
|
|
28
|
+
*/
|
|
29
|
+
export interface SchemaPushBody {
|
|
30
|
+
formatVersion: typeof STORY_SCHEMA_FORMAT_VERSION;
|
|
31
|
+
version: string;
|
|
32
|
+
stories: any;
|
|
33
|
+
storyCount: number;
|
|
34
|
+
zoneMappings?: any;
|
|
35
|
+
fieldBindings?: any;
|
|
36
|
+
diagnostics?: any;
|
|
37
|
+
}
|
|
38
|
+
export interface NewBindingNote {
|
|
39
|
+
docType: string;
|
|
40
|
+
sliceType: string;
|
|
41
|
+
sliceField: string;
|
|
42
|
+
sourcePath: string;
|
|
43
|
+
message: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Surfaced by `schemas.push` when the ref-relevant schema view has shifted
|
|
47
|
+
* since the last successful project-wide backfill (ADR-062 §D22). The CLI
|
|
48
|
+
* prints `command` after the success line so the user has the exact
|
|
49
|
+
* invocation ready to copy-paste. Absent on pushes that don't change the
|
|
50
|
+
* ref-relevant view (which is the steady-state case).
|
|
51
|
+
*/
|
|
52
|
+
export interface BackfillRecommendation {
|
|
53
|
+
command: string;
|
|
54
|
+
reason: string;
|
|
55
|
+
}
|
|
56
|
+
export interface SchemaPushResult {
|
|
57
|
+
projectionSeq: number;
|
|
58
|
+
status: 'pushed' | 'unchanged';
|
|
59
|
+
version: string;
|
|
60
|
+
newBindingNotes: NewBindingNote[];
|
|
61
|
+
/** Optional recommendation — see `BackfillRecommendation`. */
|
|
62
|
+
recommendBackfill?: BackfillRecommendation;
|
|
63
|
+
}
|
|
64
|
+
export interface SchemaPushFence {
|
|
65
|
+
expectedBaseVersion: string | null;
|
|
66
|
+
planDigest: string;
|
|
67
|
+
}
|
|
68
|
+
export interface ProjectRequestOptions {
|
|
69
|
+
signal?: AbortSignal;
|
|
70
|
+
timeoutMs?: number;
|
|
71
|
+
fetchImpl?: typeof fetch;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Convert the core serializer output into the exact Pattern D push body.
|
|
75
|
+
*
|
|
76
|
+
* Keep this as the only schema → wire-shape mapper used by push/sync/init/link
|
|
77
|
+
* so new schema sidecars cannot be dropped by one call site.
|
|
78
|
+
*/
|
|
79
|
+
export declare function schemaToPushBody(schema: SerializedSchema): SchemaPushBody;
|
|
80
|
+
/**
|
|
81
|
+
* POST `schemas.push` via Pattern D. Throws with a readable message on
|
|
82
|
+
* failure — caller decides whether to exit / warn.
|
|
83
|
+
*
|
|
84
|
+
* @param siteUrl Central's `.convex.site` base URL (no trailing slash).
|
|
85
|
+
* @param token Short-lived Storystick JWT with `scope: 'write' | 'full'`.
|
|
86
|
+
* @param body Resolved schema payload.
|
|
87
|
+
*/
|
|
88
|
+
export declare function pushSchema(siteUrl: string, token: string, body: SchemaPushBody, fence: SchemaPushFence, options?: ProjectRequestOptions): Promise<SchemaPushResult>;
|
|
89
|
+
export declare function fetchSchemaChangePlan(siteUrl: string, token: string, body: SchemaPushBody, options?: ProjectRequestOptions): Promise<SchemaChangePlanV1>;
|
|
90
|
+
export declare function schemaPlanFence(plan: SchemaChangePlanV1): SchemaPushFence;
|
|
91
|
+
export declare function assertSchemaPlanAllowed(plan: SchemaChangePlanV1): void;
|
|
92
|
+
/**
|
|
93
|
+
* Exchange a long-lived API key for a short-lived Storystick JWT. The CLI
|
|
94
|
+
* is a server-to-server consumer: `X-Storystick-Client: sdk` signals
|
|
95
|
+
* that path to Central's CSRF check (see `apps/studio/convex/_csrf.ts`).
|
|
96
|
+
*
|
|
97
|
+
* @param siteUrl Central's `.convex.site` base URL (no trailing slash).
|
|
98
|
+
* @param apiKey Secret (`ss_sk_*`) API key.
|
|
99
|
+
*/
|
|
100
|
+
export declare function fetchSchemaPushToken(siteUrl: string, apiKey: string, options?: ProjectRequestOptions): Promise<string>;
|