@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,49 @@
|
|
|
1
|
+
# Stories and Next.js
|
|
2
|
+
|
|
3
|
+
Story files are the content grammar, not React components. Read local examples
|
|
4
|
+
before editing. A slice uses `kind: slice`; reusable field fragments and `use`
|
|
5
|
+
keep common content and style definitions shared. Add its ID to the appropriate
|
|
6
|
+
page composition's allowed choices. Preserve existing choices and their options.
|
|
7
|
+
Reference an existing document type for linked case studies or articles rather
|
|
8
|
+
than copying that document's content into another schema.
|
|
9
|
+
|
|
10
|
+
A typical local slice:
|
|
11
|
+
|
|
12
|
+
```yaml
|
|
13
|
+
story: numberedProcess
|
|
14
|
+
kind: slice
|
|
15
|
+
label: Numbered process
|
|
16
|
+
use: [sectionStyle]
|
|
17
|
+
---
|
|
18
|
+
fields:
|
|
19
|
+
content:
|
|
20
|
+
headline:
|
|
21
|
+
type: text
|
|
22
|
+
steps:
|
|
23
|
+
type: processStep
|
|
24
|
+
array: true
|
|
25
|
+
min: 2
|
|
26
|
+
max: 8
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Use the repository's generated types and component registry after generation.
|
|
30
|
+
Do not hand-edit generated bindings or change the public SDK to fit a new slice.
|
|
31
|
+
|
|
32
|
+
The Next.js starter initializes its existing server facade:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { createSite } from '@storystick/next/server'
|
|
36
|
+
import * as definition from '#storystick/query'
|
|
37
|
+
export const site = createSite({ definition })
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Its route calls `site.resolvePage(props)`, handles `redirect` and `document`
|
|
41
|
+
results, and renders the existing `<SliceZone slices={...} components={...} />`.
|
|
42
|
+
Follow the repository's actual composition field name and metadata conventions.
|
|
43
|
+
Other queries should use its existing generated client, for example Coast's
|
|
44
|
+
`client.query('siteSettings').selectAll().fetch()`. Inspect nearby queries and
|
|
45
|
+
the installed version's types before choosing projections or relations.
|
|
46
|
+
|
|
47
|
+
Preview context is provided by Storystick's integration. Do not add perspective
|
|
48
|
+
parameters to public SDK APIs, mutate the site's DOM, or bypass the existing
|
|
49
|
+
preview session protocol. Values crossing an RSC boundary must be plain data.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pretty-print the optional `recommendBackfill` block surfaced by
|
|
3
|
+
* `schemas.push` (ADR-062 §D22). Lands two muted lines after the push
|
|
4
|
+
* success line so the user has the exact CLI invocation ready to copy.
|
|
5
|
+
* No-op when the recommendation is absent — the steady-state push path
|
|
6
|
+
* stays silent.
|
|
7
|
+
*
|
|
8
|
+
* Extracted from `cli.ts` so the format helper stays unit-testable
|
|
9
|
+
* without pulling in cac / process.exit / device-auth side effects.
|
|
10
|
+
*/
|
|
11
|
+
import type { BackfillRecommendation } from './push-client.js';
|
|
12
|
+
export declare function printBackfillRecommendation(rec: BackfillRecommendation | undefined): void;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pretty-print the optional `recommendBackfill` block surfaced by
|
|
3
|
+
* `schemas.push` (ADR-062 §D22). Lands two muted lines after the push
|
|
4
|
+
* success line so the user has the exact CLI invocation ready to copy.
|
|
5
|
+
* No-op when the recommendation is absent — the steady-state push path
|
|
6
|
+
* stays silent.
|
|
7
|
+
*
|
|
8
|
+
* Extracted from `cli.ts` so the format helper stays unit-testable
|
|
9
|
+
* without pulling in cac / process.exit / device-auth side effects.
|
|
10
|
+
*/
|
|
11
|
+
import pc from 'picocolors';
|
|
12
|
+
import { logMessage } from './output.js';
|
|
13
|
+
export function printBackfillRecommendation(rec) {
|
|
14
|
+
if (!rec)
|
|
15
|
+
return;
|
|
16
|
+
logMessage(pc.dim(`${rec.reason} Recommended:`));
|
|
17
|
+
logMessage(pc.dim(` ${rec.command}`));
|
|
18
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ImportTransport } from './content-import.js';
|
|
2
|
+
export { assetImportTransport, uploadBudgetTransport } from './content-import.js';
|
|
3
|
+
export interface UploadBudgetInput {
|
|
4
|
+
sessions: number;
|
|
5
|
+
bytes: number;
|
|
6
|
+
reason: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function readUploadBudget(file: string): Promise<{
|
|
9
|
+
requestId: string;
|
|
10
|
+
input: UploadBudgetInput;
|
|
11
|
+
}>;
|
|
12
|
+
export interface ImportAssetInput {
|
|
13
|
+
namespace: string;
|
|
14
|
+
key: string;
|
|
15
|
+
assetType: 'image' | 'file' | 'video';
|
|
16
|
+
displayName: string;
|
|
17
|
+
originalFilename: string;
|
|
18
|
+
declaredMimeType: string;
|
|
19
|
+
declaredSizeBytes: number;
|
|
20
|
+
declaredChecksum: string;
|
|
21
|
+
altDefault?: string;
|
|
22
|
+
altDefaultMode?: 'text' | 'decorative';
|
|
23
|
+
captionDefault?: string;
|
|
24
|
+
copyright?: string;
|
|
25
|
+
focalPoint?: {
|
|
26
|
+
x: number;
|
|
27
|
+
y: number;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export interface ImportAssetReceipt {
|
|
31
|
+
state: 'planned' | 'pending' | 'ready' | 'renewable';
|
|
32
|
+
planDigest: string;
|
|
33
|
+
inputDigest: string;
|
|
34
|
+
sizeBytes?: number;
|
|
35
|
+
assetId?: string;
|
|
36
|
+
version?: number;
|
|
37
|
+
uploadSessionId?: string;
|
|
38
|
+
expiresAt?: number;
|
|
39
|
+
reason?: string;
|
|
40
|
+
}
|
|
41
|
+
export declare function readAssetImport(manifestPath: string): Promise<{
|
|
42
|
+
file: string;
|
|
43
|
+
input: ImportAssetInput;
|
|
44
|
+
}>;
|
|
45
|
+
export declare function prepareAssetImport(transport: ImportTransport, input: ImportAssetInput): Promise<ImportAssetReceipt>;
|
|
46
|
+
export declare function applyAssetImport(transport: ImportTransport, file: string, input: ImportAssetInput, planDigest: string, options?: {
|
|
47
|
+
signal?: AbortSignal;
|
|
48
|
+
fetchImpl?: typeof fetch;
|
|
49
|
+
renewSessionId?: string;
|
|
50
|
+
}): Promise<ImportAssetReceipt>;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/** Original-file import client. Only delegated PUTs leave the gateway path. */
|
|
2
|
+
import { readFile, realpath, stat, open } from 'node:fs/promises';
|
|
3
|
+
import { createReadStream } from 'node:fs';
|
|
4
|
+
import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
+
import { preflightMediaUpload } from '@storystick/types/asset-upload-policy';
|
|
8
|
+
import { importObject } from '@storystick/core/content-import';
|
|
9
|
+
import { StorystickError } from './cli-error.js';
|
|
10
|
+
export { assetImportTransport, uploadBudgetTransport } from './content-import.js';
|
|
11
|
+
export async function readUploadBudget(file) {
|
|
12
|
+
const { requestId, ...input } = importObject(JSON.parse(await readFile(file, 'utf8')), 'upload budget');
|
|
13
|
+
if (typeof requestId !== 'string' || !/^[a-zA-Z0-9_-]{1,80}$/.test(requestId) ||
|
|
14
|
+
Object.keys(input).some(k => !['sessions', 'bytes', 'reason'].includes(k)) ||
|
|
15
|
+
typeof input.reason !== 'string' || !input.reason.trim() || input.reason.length > 512 ||
|
|
16
|
+
!Number.isSafeInteger(input.sessions) || Number(input.sessions) < 1 || !Number.isSafeInteger(input.bytes) || Number(input.bytes) < 1) {
|
|
17
|
+
throw new Error('Expected requestId, positive integer sessions and bytes, and an explicit reason.');
|
|
18
|
+
}
|
|
19
|
+
return { requestId, input: input };
|
|
20
|
+
}
|
|
21
|
+
async function fingerprint(file, signal) {
|
|
22
|
+
const hash = createHash('sha256');
|
|
23
|
+
let size = 0;
|
|
24
|
+
for await (const bytes of createReadStream(file, { signal })) {
|
|
25
|
+
hash.update(bytes);
|
|
26
|
+
size += bytes.length;
|
|
27
|
+
}
|
|
28
|
+
return { size, checksum: hash.digest('hex') };
|
|
29
|
+
}
|
|
30
|
+
export async function readAssetImport(manifestPath) {
|
|
31
|
+
const directory = await realpath(dirname(resolve(manifestPath)));
|
|
32
|
+
const manifest = importObject(JSON.parse(await readFile(manifestPath, 'utf8')), 'asset manifest');
|
|
33
|
+
const { file: filename, mimeType, ...metadata } = manifest;
|
|
34
|
+
const allowed = ['namespace', 'key', 'displayName', 'altDefault', 'altDefaultMode', 'captionDefault', 'copyright', 'focalPoint'];
|
|
35
|
+
if (Object.keys(metadata).some(k => !allowed.includes(k)))
|
|
36
|
+
throw new Error('Unknown asset manifest property.');
|
|
37
|
+
if (typeof filename !== 'string' || isAbsolute(filename))
|
|
38
|
+
throw new Error('Asset file must be relative to its manifest.');
|
|
39
|
+
const file = await realpath(resolve(directory, filename)), rel = relative(directory, file);
|
|
40
|
+
if (rel === '..' || rel.startsWith('../') || isAbsolute(rel))
|
|
41
|
+
throw new Error('Asset file must be inside its manifest directory.');
|
|
42
|
+
for (const name of ['namespace', 'key', 'displayName'])
|
|
43
|
+
if (typeof metadata[name] !== 'string' || !metadata[name].trim())
|
|
44
|
+
throw new Error(`Asset ${name} is required.`);
|
|
45
|
+
if (mimeType !== undefined && typeof mimeType !== 'string')
|
|
46
|
+
throw new Error('mimeType must be a string.');
|
|
47
|
+
const size = (await stat(file)).size, originalFilename = basename(file);
|
|
48
|
+
const policy = preflightMediaUpload({ declaredMimeType: mimeType, declaredSizeBytes: size, originalFilename });
|
|
49
|
+
if (!policy.ok)
|
|
50
|
+
throw new Error(policy.message);
|
|
51
|
+
const observed = await fingerprint(file);
|
|
52
|
+
if (observed.size !== size)
|
|
53
|
+
throw new Error('Asset file changed while reading.');
|
|
54
|
+
return { file, input: { ...metadata, assetType: policy.assetType, originalFilename,
|
|
55
|
+
declaredMimeType: policy.mimeType, declaredSizeBytes: size, declaredChecksum: observed.checksum } };
|
|
56
|
+
}
|
|
57
|
+
export async function prepareAssetImport(transport, input) {
|
|
58
|
+
return transport({ operation: 'plan', input });
|
|
59
|
+
}
|
|
60
|
+
async function transfer(capability, bytes, single, options) {
|
|
61
|
+
for (let attempt = 0;; attempt++) {
|
|
62
|
+
const receipt = await capability();
|
|
63
|
+
if (receipt.state === 'ready')
|
|
64
|
+
return receipt;
|
|
65
|
+
assertNotRenewable(receipt);
|
|
66
|
+
if (!receipt.uploadUrl || receipt.uploadMethod !== 'PUT')
|
|
67
|
+
throw new Error('Gateway did not return an upload capability.');
|
|
68
|
+
try {
|
|
69
|
+
const response = await (options.fetchImpl ?? fetch)(receipt.uploadUrl, {
|
|
70
|
+
method: 'PUT', headers: receipt.uploadHeaders, body: bytes,
|
|
71
|
+
signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(120_000)]) : AbortSignal.timeout(120_000),
|
|
72
|
+
redirect: 'error',
|
|
73
|
+
});
|
|
74
|
+
await response.body?.cancel();
|
|
75
|
+
// A create-once single PUT may have succeeded before its reply was lost.
|
|
76
|
+
if (response.ok || (single && response.status === 412))
|
|
77
|
+
return response;
|
|
78
|
+
throw new StorystickError('Original-file transfer did not complete.', {
|
|
79
|
+
status: response.status, retryable: [403, 408, 429, 500, 502, 503, 504].includes(response.status),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (options.signal?.aborted || attempt >= 4 || !(error instanceof TypeError || (error instanceof Error && error.name === 'TimeoutError') || (error instanceof StorystickError && error.retryable)))
|
|
84
|
+
throw error;
|
|
85
|
+
await delay(500 * 2 ** attempt, undefined, { signal: options.signal });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function assertNotRenewable(receipt) {
|
|
90
|
+
if (receipt.state === 'renewable')
|
|
91
|
+
throw new StorystickError(`Upload ${receipt.uploadSessionId} requires explicit renewal. Review its failure and apply with --renew ${receipt.uploadSessionId}.`, {
|
|
92
|
+
code: 'storystick/CONTENT_IMPORT_CONFLICT', status: 409, retryable: false, payload: receipt,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
export async function applyAssetImport(transport, file, input, planDigest, options = {}) {
|
|
96
|
+
const observed = await fingerprint(file, options.signal);
|
|
97
|
+
if (observed.size !== input.declaredSizeBytes || observed.checksum !== input.declaredChecksum)
|
|
98
|
+
throw new Error('Asset file changed after planning.');
|
|
99
|
+
const request = { input, planDigest };
|
|
100
|
+
const begin = () => transport({ operation: options.renewSessionId ? 'renew' : 'begin', ...request,
|
|
101
|
+
...(options.renewSessionId ? { expectedUploadSessionId: options.renewSessionId } : {}) });
|
|
102
|
+
const receipt = await begin();
|
|
103
|
+
if (receipt.state === 'ready')
|
|
104
|
+
return receipt;
|
|
105
|
+
assertNotRenewable(receipt);
|
|
106
|
+
if (receipt.state !== 'pending')
|
|
107
|
+
throw new Error('Gateway did not admit the asset upload.');
|
|
108
|
+
if (receipt.uploadMode === 'multipart') {
|
|
109
|
+
const partSize = receipt.partSizeBytes, totalParts = receipt.totalParts;
|
|
110
|
+
if (!Number.isSafeInteger(partSize) || partSize < 1 || partSize > 16 * 1024 * 1024 ||
|
|
111
|
+
!Number.isSafeInteger(totalParts) || totalParts !== Math.ceil(input.declaredSizeBytes / partSize))
|
|
112
|
+
throw new Error('Invalid multipart contract.');
|
|
113
|
+
const handle = await open(file, 'r');
|
|
114
|
+
try {
|
|
115
|
+
for (let partNumber = 1; partNumber <= totalParts; partNumber++) {
|
|
116
|
+
options.signal?.throwIfAborted();
|
|
117
|
+
const offset = (partNumber - 1) * partSize, sizeBytes = Math.min(partSize, input.declaredSizeBytes - offset);
|
|
118
|
+
const bytes = Buffer.alloc(sizeBytes);
|
|
119
|
+
let read = 0;
|
|
120
|
+
while (read < sizeBytes) {
|
|
121
|
+
const next = await handle.read(bytes, read, sizeBytes - read, offset + read);
|
|
122
|
+
if (!next.bytesRead)
|
|
123
|
+
throw new Error('Asset file changed during transfer.');
|
|
124
|
+
read += next.bytesRead;
|
|
125
|
+
}
|
|
126
|
+
const checksumMd5 = createHash('md5').update(bytes).digest('base64');
|
|
127
|
+
if (receipt.parts?.some(p => p.partNumber === partNumber && p.sizeBytes === sizeBytes && p.checksumMd5 === checksumMd5))
|
|
128
|
+
continue;
|
|
129
|
+
const part = { partNumber, checksumMd5 };
|
|
130
|
+
const response = await transfer(() => transport({ operation: 'prepare-part', ...request, part }), bytes, false, options);
|
|
131
|
+
if (!(response instanceof Response))
|
|
132
|
+
return response;
|
|
133
|
+
const etag = response.headers.get('etag');
|
|
134
|
+
if (!etag)
|
|
135
|
+
throw new Error('Multipart response has no ETag. The upload remains resumable.');
|
|
136
|
+
await transport({ operation: 'record-part', ...request, part: { ...part, etag, sizeBytes } });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
await handle.close();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
const bytes = await readFile(file);
|
|
145
|
+
if (bytes.length !== input.declaredSizeBytes || createHash('sha256').update(bytes).digest('hex') !== input.declaredChecksum)
|
|
146
|
+
throw new Error('Asset file changed after planning.');
|
|
147
|
+
let first = true;
|
|
148
|
+
const response = await transfer(() => { if (first) {
|
|
149
|
+
first = false;
|
|
150
|
+
return Promise.resolve(receipt);
|
|
151
|
+
} ; return begin(); }, bytes, true, options);
|
|
152
|
+
if (!(response instanceof Response))
|
|
153
|
+
return response;
|
|
154
|
+
}
|
|
155
|
+
const result = await transport({ operation: 'finalize', ...request });
|
|
156
|
+
assertNotRenewable(result);
|
|
157
|
+
if (result.state !== 'ready')
|
|
158
|
+
throw new Error('Asset import has no verified ready receipt.');
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { HubApiOptions } from './hub-client.js';
|
|
2
|
+
/** Reuses the operator session. No project secret, browser login or token minting. */
|
|
3
|
+
export declare function readProjectCapability(projectShortId: string, request: Record<string, unknown>, options?: Pick<HubApiOptions, 'gatewayUrl' | 'rootDir' | 'fetchImpl' | 'signal'>): Promise<Record<string, unknown>>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { callHubApi } from './hub-client.js';
|
|
2
|
+
/** Reuses the operator session. No project secret, browser login or token minting. */
|
|
3
|
+
export function readProjectCapability(projectShortId, request, options = {}) {
|
|
4
|
+
return callHubApi('/api/cli/project-read', {
|
|
5
|
+
...options, method: 'POST', body: { projectShortId, request },
|
|
6
|
+
interactiveAuth: false, timeoutMs: 30_000,
|
|
7
|
+
});
|
|
8
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
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
|
+
/**
|
|
36
|
+
* Resolve the token-file directory per XDG. Public so tests can stub
|
|
37
|
+
* HOME + XDG env vars and assert the resolved path.
|
|
38
|
+
*/
|
|
39
|
+
export declare function authDir(): string;
|
|
40
|
+
export declare function authFilePath(issuer: string): string;
|
|
41
|
+
export interface StoredAuth {
|
|
42
|
+
/** Canonical gateway that issued this credential. Never inferred from a project. */
|
|
43
|
+
issuer: string;
|
|
44
|
+
/** Signed CLI JWT (typ: "cli"). */
|
|
45
|
+
token: string;
|
|
46
|
+
/** Epoch-ms expiry copied from the JWT claim. Checked locally to avoid
|
|
47
|
+
* one bogus network round-trip when the token has obviously aged out. */
|
|
48
|
+
expiresAt: number;
|
|
49
|
+
/** Convex user id — cached so `storystick info` can say who's signed in
|
|
50
|
+
* without a ping. Not security-sensitive. */
|
|
51
|
+
userId: string;
|
|
52
|
+
/** Cached email, same rationale as userId. */
|
|
53
|
+
email?: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Read the cached CLI session. Returns null when:
|
|
57
|
+
* - file does not exist
|
|
58
|
+
* - file is unreadable (permissions / corruption)
|
|
59
|
+
* - JSON is malformed
|
|
60
|
+
* - required fields are missing
|
|
61
|
+
* - `expiresAt` has passed (local preflight — the Hub is the source of truth)
|
|
62
|
+
*/
|
|
63
|
+
export declare function getAuthToken(issuer: string): string | null;
|
|
64
|
+
/** An unbound/corrupt credential requires a deliberate login, never inference. */
|
|
65
|
+
export declare function requiresExplicitAuthLogin(issuer: string): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Lower-level read — returns the whole struct so callers like `whoami`
|
|
68
|
+
* can display userId/email without re-parsing.
|
|
69
|
+
*/
|
|
70
|
+
export declare function loadAuth(issuer: string): StoredAuth | null;
|
|
71
|
+
/**
|
|
72
|
+
* Persist this issuer’s session atomically. Separate files keep concurrent
|
|
73
|
+
* logins to different environments from replacing each other. Old unbound
|
|
74
|
+
* auth.json files are never loaded; their owners must log in again.
|
|
75
|
+
*/
|
|
76
|
+
export declare function saveAuth(auth: StoredAuth, options?: {
|
|
77
|
+
faultAt?: 'before-rename';
|
|
78
|
+
}): void;
|
|
79
|
+
/**
|
|
80
|
+
* Wipe the token file. Missing file is a no-op — `logout` should always
|
|
81
|
+
* succeed (same intent as `rm -f`).
|
|
82
|
+
*/
|
|
83
|
+
export declare function clearAuthToken(issuer: string): Promise<void>;
|
|
84
|
+
export declare function revokeAuthSession(options?: {
|
|
85
|
+
gatewayUrl?: string;
|
|
86
|
+
rootDir?: string;
|
|
87
|
+
signal?: AbortSignal;
|
|
88
|
+
timeoutMs?: number;
|
|
89
|
+
fetchImpl?: typeof fetch;
|
|
90
|
+
}): Promise<'revoked' | 'not-authenticated'>;
|
|
91
|
+
export declare function logoutAuthSession(options?: {
|
|
92
|
+
localOnly?: boolean;
|
|
93
|
+
gatewayUrl?: string;
|
|
94
|
+
rootDir?: string;
|
|
95
|
+
signal?: AbortSignal;
|
|
96
|
+
timeoutMs?: number;
|
|
97
|
+
fetchImpl?: typeof fetch;
|
|
98
|
+
}): Promise<'revoked' | 'not-authenticated' | 'local-only'>;
|
|
99
|
+
/**
|
|
100
|
+
* Return `{ userId, email }` from the local cache, or null when nothing is
|
|
101
|
+
* cached (or the cache has expired). Backs `storystick info` / `whoami`.
|
|
102
|
+
*/
|
|
103
|
+
export declare function whoami(options?: {
|
|
104
|
+
gatewayUrl?: string;
|
|
105
|
+
rootDir?: string;
|
|
106
|
+
}): Promise<{
|
|
107
|
+
userId: string;
|
|
108
|
+
email: string | null;
|
|
109
|
+
} | null>;
|
|
110
|
+
export interface LoginOptions {
|
|
111
|
+
/** Override Central's HTTP-actions URL (convex.site). Rarely useful in
|
|
112
|
+
* practice — default works for prod. Tests stub this to point at a mock. */
|
|
113
|
+
gatewayUrl?: string;
|
|
114
|
+
/** Override the Hub URL shown to the user. Used in dev where the URL is
|
|
115
|
+
* `http://storystick.dojo:3000` rather than the prod `storystick.app`. */
|
|
116
|
+
hubUrl?: string;
|
|
117
|
+
/** Optional device label shown on the Hub approval page ("MacBook Pro de
|
|
118
|
+
* Tanguy"). Defaults to the machine's hostname if not provided. */
|
|
119
|
+
deviceName?: string;
|
|
120
|
+
/** Hook called exactly once with the user-facing instructions (verification
|
|
121
|
+
* URL + userCode + expiresIn). The CLI prints them from this callback. */
|
|
122
|
+
onPrompt?: (data: {
|
|
123
|
+
issuer: string;
|
|
124
|
+
verificationUrl: string;
|
|
125
|
+
userCode: string;
|
|
126
|
+
expiresIn: number;
|
|
127
|
+
}) => void;
|
|
128
|
+
/** Hook called on each poll cycle while the status is `pending`. Lets the
|
|
129
|
+
* caller update a spinner / refresh a TTY line. */
|
|
130
|
+
onPoll?: () => void;
|
|
131
|
+
/** Fetch implementation — tests inject a mock. Default is `globalThis.fetch`. */
|
|
132
|
+
fetchImpl?: typeof fetch;
|
|
133
|
+
/** Override the poll cadence (ms). Tests drop this to 1 so they don't wait
|
|
134
|
+
* 3 seconds per iteration. */
|
|
135
|
+
pollIntervalMs?: number;
|
|
136
|
+
/** Abort signal for Ctrl-C / programmatic cancel. */
|
|
137
|
+
signal?: AbortSignal;
|
|
138
|
+
/** Deadline for each start/poll HTTP request. */
|
|
139
|
+
timeoutMs?: number;
|
|
140
|
+
/** Root used to resolve canonical runtime env overrides. */
|
|
141
|
+
rootDir?: string;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Drive the full device-auth flow.
|
|
145
|
+
*
|
|
146
|
+
* On success: writes `auth.json`, returns the token.
|
|
147
|
+
* On failure: throws a human-readable `Error`; nothing written.
|
|
148
|
+
*
|
|
149
|
+
* The function does NOT print anything directly — printing is the CLI
|
|
150
|
+
* command's responsibility. This keeps `deviceAuthLogin` testable without
|
|
151
|
+
* piping stdout around.
|
|
152
|
+
*/
|
|
153
|
+
export declare function deviceAuthLogin(options?: LoginOptions): Promise<string>;
|