@shardflux/cli 0.14.5 → 0.15.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/CHANGELOG.md +61 -0
- package/README.md +72 -10
- package/dist/account-args.js +28 -7
- package/dist/account-commands.js +140 -11
- package/dist/acp-catalog.js +2 -2
- package/dist/acp-login.d.ts +20 -0
- package/dist/acp-login.js +122 -0
- package/dist/acp.js +36 -13
- package/dist/agent-commands.js +55 -4
- package/dist/agent-git-commands.js +2 -1
- package/dist/agent-reachability.js +1 -1
- package/dist/agent-target-commands.js +26 -1
- package/dist/agent-targets.d.ts +7 -0
- package/dist/agent-targets.js +44 -32
- package/dist/args.d.ts +3 -1
- package/dist/args.js +21 -9
- package/dist/browser-login.d.ts +35 -0
- package/dist/browser-login.js +145 -0
- package/dist/codex-cloud.d.ts +49 -0
- package/dist/codex-cloud.js +157 -0
- package/dist/command-center-actions.js +7 -0
- package/dist/command-center-renderer.d.ts +3 -0
- package/dist/command-center-renderer.js +42 -38
- package/dist/command-center.js +65 -11
- package/dist/conversation-terminal.js +19 -0
- package/dist/conversations.d.ts +1 -1
- package/dist/conversations.js +21 -8
- package/dist/credential-prompts.d.ts +4 -2
- package/dist/credential-prompts.js +38 -16
- package/dist/device-bridge.d.ts +13 -0
- package/dist/device-bridge.js +14 -4
- package/dist/device-helper.js +2 -1
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +15 -2
- package/dist/handlers.js +2 -1
- package/dist/initial-prompt.d.ts +26 -0
- package/dist/initial-prompt.js +196 -0
- package/dist/legal.d.ts +36 -0
- package/dist/legal.js +44 -0
- package/dist/main.js +6 -2
- package/dist/menubar.d.ts +8 -0
- package/dist/menubar.js +9 -1
- package/dist/onboarding-app/AGENTS.md +19 -0
- package/dist/onboarding-app/CLAUDE.md +19 -0
- package/dist/onboarding-app/README.md +12 -0
- package/dist/onboarding-app/dev.mjs +18 -0
- package/dist/onboarding-app/index.html +25 -0
- package/dist/onboarding-app/package.json +12 -0
- package/dist/onboarding-app/server.mjs +41 -0
- package/dist/onboarding-sample.d.ts +3 -0
- package/dist/onboarding-sample.js +29 -0
- package/dist/onboarding.d.ts +258 -0
- package/dist/onboarding.js +371 -0
- package/dist/terminal-brand.d.ts +5 -0
- package/dist/terminal-brand.js +18 -0
- package/package.json +3 -3
package/dist/errors.js
CHANGED
|
@@ -66,6 +66,19 @@ export class CodedError extends HintedError {
|
|
|
66
66
|
this.details = details;
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* A command that can create an account ran without the agreement to the Terms of Service (0.15.0+): no
|
|
71
|
+
* --accept-terms / SHARDFLUX_ACCEPT_TERMS=1 and no terminal to ask on, or the person answered no. Nothing was sent.
|
|
72
|
+
* Code `terms_not_accepted`, exit 2 (usage); `details` has both links and the flag.
|
|
73
|
+
*/
|
|
74
|
+
export class TermsNotAcceptedError extends CodedError {
|
|
75
|
+
name = 'TermsNotAcceptedError';
|
|
76
|
+
constructor(declined = false) {
|
|
77
|
+
super('terms_not_accepted', declined
|
|
78
|
+
? 'Not signed up: creating an account needs your agreement to the Terms of Service and Privacy Policy.'
|
|
79
|
+
: 'Re-run with --accept-terms to agree to the Terms of Service (https://shardflux.dev/terms) and Privacy Policy (https://shardflux.dev/privacy); creating an account needs that agreement.', 'read https://shardflux.dev/terms and https://shardflux.dev/privacy, then re-run with --accept-terms (or set SHARDFLUX_ACCEPT_TERMS=1)', { terms_url: 'https://shardflux.dev/terms', privacy_url: 'https://shardflux.dev/privacy', flag: '--accept-terms' });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
69
82
|
/** No usable credential: SHARDFLUX_API_KEY / the saved key, or the session, missing or malformed (never carries it). */
|
|
70
83
|
export class AuthConfigError extends HintedError {
|
|
71
84
|
name = 'AuthConfigError';
|
|
@@ -139,7 +152,7 @@ export class ExecSessionRunningError extends HintedError {
|
|
|
139
152
|
this.session = session;
|
|
140
153
|
}
|
|
141
154
|
}
|
|
142
|
-
const AUTH_CODES = new Set(['unauthenticated', 'invalid_credentials', 'token_invalid', 'forbidden', 'mfa_required', 'email_unverified', 'step_up_required', 'csrf_failed']);
|
|
155
|
+
const AUTH_CODES = new Set(['unauthenticated', 'invalid_credentials', 'token_invalid', 'forbidden', 'mfa_required', 'email_unverified', 'step_up_required', 'csrf_failed', 'access_denied', 'expired_token']);
|
|
143
156
|
const KEY_PATTERN = /sfk_[A-Za-z0-9]{8,}_[A-Za-z0-9]+/g;
|
|
144
157
|
/** User session tokens (`sfu_` + 43 base64url characters), also cut short. */
|
|
145
158
|
const SESSION_PATTERN = /sfu_[A-Za-z0-9_-]{16,}/g;
|
|
@@ -261,7 +274,7 @@ export function describeError(err) {
|
|
|
261
274
|
export function exitCodeFor(err, interrupted = false) {
|
|
262
275
|
if (interrupted || err instanceof InterruptedError)
|
|
263
276
|
return EXIT.interrupted;
|
|
264
|
-
if (err instanceof UsageError || err instanceof TemplateFileError)
|
|
277
|
+
if (err instanceof UsageError || err instanceof TemplateFileError || err instanceof TermsNotAcceptedError)
|
|
265
278
|
return EXIT.usage;
|
|
266
279
|
if (err instanceof AuthConfigError || err instanceof MfaPendingError)
|
|
267
280
|
return EXIT.auth;
|
package/dist/handlers.js
CHANGED
|
@@ -15,4 +15,5 @@ import { MENUBAR_HANDLERS } from "./menubar.js";
|
|
|
15
15
|
import { UPDATE_HANDLERS } from "./update.js";
|
|
16
16
|
import { FILESYSTEM_HANDLERS } from "./filesystem-commands.js";
|
|
17
17
|
import { CONVERSATION_HANDLERS as LOCAL_CONVERSATION_HANDLERS } from "./conversation-local.js";
|
|
18
|
-
|
|
18
|
+
import { CODEX_CLOUD_HANDLERS } from "./codex-cloud.js";
|
|
19
|
+
export const HANDLERS = { ...CODEX_CLOUD_HANDLERS, ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...UPDATE_HANDLERS };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Ctx } from './commands.js';
|
|
2
|
+
import type { Values } from './args.js';
|
|
3
|
+
import type { TerminalConnection } from './agent-terminal.js';
|
|
4
|
+
import type { CellClient } from '@shardflux/sdk';
|
|
5
|
+
/** The user chose this repository. Trust exactly that new VM worktree before launching Codex's first turn. */
|
|
6
|
+
export declare function prepareInitialCodexProject(cell: CellClient, cwd: string, ptyId: string): Promise<void>;
|
|
7
|
+
export declare function readInitialPrompt(ctx: Ctx, values: Values): Promise<string | undefined>;
|
|
8
|
+
/** Images for a new conversation's first message. They are held in memory and written only into the conversation VM. */
|
|
9
|
+
export interface InitialImage {
|
|
10
|
+
path: string;
|
|
11
|
+
data: Uint8Array;
|
|
12
|
+
type: 'png' | 'jpeg' | 'gif' | 'webp';
|
|
13
|
+
}
|
|
14
|
+
export declare const ATTACHMENT_DIR = "/home/user/.shardflux/attachments";
|
|
15
|
+
export declare const MAX_INITIAL_IMAGES = 5;
|
|
16
|
+
export declare const MAX_INITIAL_IMAGE_BYTES: number;
|
|
17
|
+
/** Standard input may carry several PNG files back to back (the menu bar's in-memory captures). */
|
|
18
|
+
export declare function splitImages(data: Uint8Array): Uint8Array[];
|
|
19
|
+
/** Reads and validates `--image <file|->` before any VM is allocated. */
|
|
20
|
+
export declare function readInitialImages(ctx: Ctx, values: Values, prompt: string | undefined): Promise<InitialImage[]>;
|
|
21
|
+
/** One VM path per line: Claude Code attaches each as an image when the prompt is pasted; Codex opens it with view_image. */
|
|
22
|
+
export declare function promptWithImages(prompt: string, images: readonly InitialImage[]): string;
|
|
23
|
+
/** Writes the images into the conversation VM before the first message refers to them. */
|
|
24
|
+
export declare function uploadInitialImages(cell: Pick<CellClient, 'files'>, images: readonly InitialImage[], signal?: AbortSignal): Promise<void>;
|
|
25
|
+
/** Wait for the native terminal to enable paste before submitting one first turn. No blind timing or input replay. */
|
|
26
|
+
export declare function submitInitialPrompt(connect: (signal: AbortSignal) => Promise<TerminalConnection>, prompt: string, workspaceKey: string, signal: AbortSignal, app?: 'claude' | 'codex', write?: (bytes: Uint8Array) => Promise<unknown>): Promise<void>;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { readInput, str, strs } from "./commands.js";
|
|
3
|
+
import { PtyOutputCursor } from "./agent-terminal.js";
|
|
4
|
+
import { CodedError, UsageError } from "./errors.js";
|
|
5
|
+
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
6
|
+
import { parse, stringify } from 'smol-toml';
|
|
7
|
+
/** The user chose this repository. Trust exactly that new VM worktree before launching Codex's first turn. */
|
|
8
|
+
export async function prepareInitialCodexProject(cell, cwd, ptyId) {
|
|
9
|
+
const path = '/home/user/.codex/config.toml';
|
|
10
|
+
let source = '', revision = 'absent';
|
|
11
|
+
try {
|
|
12
|
+
const old = await cell.files.readWithInfo(path);
|
|
13
|
+
source = new TextDecoder().decode(old.data);
|
|
14
|
+
if (!old.revision)
|
|
15
|
+
throw new Error('Codex config omitted its revision.');
|
|
16
|
+
revision = old.revision;
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (!(error instanceof ShardfluxApiError && error.status === 404))
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
const doc = parse(source);
|
|
23
|
+
const projects = doc.projects && typeof doc.projects === 'object' && !Array.isArray(doc.projects) ? doc.projects : {};
|
|
24
|
+
projects[cwd] = { ...(projects[cwd] ?? {}), trust_level: 'trusted' };
|
|
25
|
+
doc.projects = projects;
|
|
26
|
+
// The VM image owns Codex's version. An interactive updater must not intercept the first task.
|
|
27
|
+
doc.check_for_update_on_startup = false;
|
|
28
|
+
await cell.files.mkdir('/home/user/.codex', { parents: true });
|
|
29
|
+
await cell.files.patch({ path, content: stringify(doc), expectedRevision: revision, mode: '0600' });
|
|
30
|
+
// A new conversation's VM can auto-start its empty terminal before the CLI seeds/configures it.
|
|
31
|
+
// Reload that owned, never-prompted terminal so startup reads the chosen worktree's trust setting.
|
|
32
|
+
try {
|
|
33
|
+
const session = await cell.pty.get(ptyId);
|
|
34
|
+
if (session.state === 'running' || session.state === 'starting')
|
|
35
|
+
await cell.pty.close(ptyId);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (!(error instanceof ShardfluxApiError && error.status === 404))
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function readInitialPrompt(ctx, values) {
|
|
43
|
+
const prompt = str(values.prompt), file = str(values['prompt-file']);
|
|
44
|
+
if (prompt !== undefined && file !== undefined)
|
|
45
|
+
throw new UsageError('Use --prompt or --prompt-file, not both.');
|
|
46
|
+
const text = file === undefined ? prompt : new TextDecoder('utf-8', { fatal: true }).decode(await readInput(ctx, file, 262144, '--prompt-file'));
|
|
47
|
+
if (text !== undefined && (!text.trim() || text.includes('\0') || text.includes('\x1b') || Buffer.byteLength(text) > 262144)) {
|
|
48
|
+
throw new UsageError('The initial prompt must contain text, be at most 256 KiB, and contain no NUL or terminal escape characters.');
|
|
49
|
+
}
|
|
50
|
+
if (text !== undefined && values.resume === true)
|
|
51
|
+
throw new UsageError('An initial prompt is for a new conversation; use conversations send for an existing conversation.');
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
54
|
+
export const ATTACHMENT_DIR = '/home/user/.shardflux/attachments';
|
|
55
|
+
export const MAX_INITIAL_IMAGES = 5;
|
|
56
|
+
export const MAX_INITIAL_IMAGE_BYTES = 10 << 20;
|
|
57
|
+
const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
58
|
+
function imageType(data) {
|
|
59
|
+
const ascii = (from, to) => Buffer.from(data.subarray(from, to)).toString('latin1');
|
|
60
|
+
if (PNG.every((b, i) => data[i] === b))
|
|
61
|
+
return 'png';
|
|
62
|
+
if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff)
|
|
63
|
+
return 'jpeg';
|
|
64
|
+
if (ascii(0, 6) === 'GIF87a' || ascii(0, 6) === 'GIF89a')
|
|
65
|
+
return 'gif';
|
|
66
|
+
if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP')
|
|
67
|
+
return 'webp';
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
/** Standard input may carry several PNG files back to back (the menu bar's in-memory captures). */
|
|
71
|
+
export function splitImages(data) {
|
|
72
|
+
if (imageType(data) !== 'png')
|
|
73
|
+
return [data];
|
|
74
|
+
const images = [];
|
|
75
|
+
let start = 0;
|
|
76
|
+
while (start < data.length) {
|
|
77
|
+
if (!PNG.every((b, i) => data[start + i] === b))
|
|
78
|
+
throw new UsageError('--image -: standard input must be one image or PNG images back to back.');
|
|
79
|
+
let at = start + 8, end = 0;
|
|
80
|
+
while (!end) {
|
|
81
|
+
if (at + 12 > data.length)
|
|
82
|
+
throw new UsageError('--image -: a PNG image on standard input is truncated.');
|
|
83
|
+
const length = Buffer.from(data.subarray(at, at + 4)).readUInt32BE(0);
|
|
84
|
+
const type = Buffer.from(data.subarray(at + 4, at + 8)).toString('latin1');
|
|
85
|
+
at += 12 + length;
|
|
86
|
+
if (at > data.length)
|
|
87
|
+
throw new UsageError('--image -: a PNG image on standard input is truncated.');
|
|
88
|
+
if (type === 'IEND')
|
|
89
|
+
end = at;
|
|
90
|
+
}
|
|
91
|
+
images.push(data.subarray(start, end));
|
|
92
|
+
start = end;
|
|
93
|
+
}
|
|
94
|
+
return images;
|
|
95
|
+
}
|
|
96
|
+
/** Reads and validates `--image <file|->` before any VM is allocated. */
|
|
97
|
+
export async function readInitialImages(ctx, values, prompt) {
|
|
98
|
+
const sources = strs(values.image);
|
|
99
|
+
if (!sources.length)
|
|
100
|
+
return [];
|
|
101
|
+
if (prompt === undefined)
|
|
102
|
+
throw new UsageError('--image needs an initial prompt: add --prompt or --prompt-file.');
|
|
103
|
+
if (sources.filter(s => s === '-').length > 1)
|
|
104
|
+
throw new UsageError('Use --image - once; standard input may carry several PNG images back to back.');
|
|
105
|
+
if (sources.includes('-') && str(values['prompt-file']) === '-')
|
|
106
|
+
throw new UsageError('--prompt-file - and --image - both read standard input. Pass one of them as a file.');
|
|
107
|
+
const id = randomUUID();
|
|
108
|
+
const images = [];
|
|
109
|
+
for (const source of sources) {
|
|
110
|
+
const data = await readInput(ctx, source, MAX_INITIAL_IMAGES * MAX_INITIAL_IMAGE_BYTES, '--image');
|
|
111
|
+
for (const part of source === '-' ? splitImages(data) : [data]) {
|
|
112
|
+
const type = imageType(part);
|
|
113
|
+
if (!type)
|
|
114
|
+
throw new UsageError(`--image ${source}: use a PNG, JPEG, GIF or WebP image.`);
|
|
115
|
+
if (part.length > MAX_INITIAL_IMAGE_BYTES)
|
|
116
|
+
throw new UsageError(`--image ${source}: each image must be at most ${MAX_INITIAL_IMAGE_BYTES >> 20} MiB.`);
|
|
117
|
+
if (images.length === MAX_INITIAL_IMAGES)
|
|
118
|
+
throw new UsageError(`Attach at most ${MAX_INITIAL_IMAGES} images.`);
|
|
119
|
+
images.push({ path: `${ATTACHMENT_DIR}/${id}-${images.length + 1}.${type === 'jpeg' ? 'jpg' : type}`, data: part, type });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return images;
|
|
123
|
+
}
|
|
124
|
+
/** One VM path per line: Claude Code attaches each as an image when the prompt is pasted; Codex opens it with view_image. */
|
|
125
|
+
export function promptWithImages(prompt, images) {
|
|
126
|
+
if (!images.length)
|
|
127
|
+
return prompt;
|
|
128
|
+
const text = `${prompt}\n\nAttached ${images.length === 1 ? 'image' : 'images'} (view ${images.length === 1 ? 'it' : 'each one'} before you start):\n${images.map(i => i.path).join('\n')}`;
|
|
129
|
+
if (Buffer.byteLength(text) > 262144)
|
|
130
|
+
throw new UsageError('The initial prompt with its image references must be at most 256 KiB.');
|
|
131
|
+
return text;
|
|
132
|
+
}
|
|
133
|
+
/** Writes the images into the conversation VM before the first message refers to them. */
|
|
134
|
+
export async function uploadInitialImages(cell, images, signal) {
|
|
135
|
+
for (const image of images) {
|
|
136
|
+
signal?.throwIfAborted();
|
|
137
|
+
await cell.files.write(image.path, image.data, { mode: '0600', createParents: true });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Wait for the native terminal to enable paste before submitting one first turn. No blind timing or input replay. */
|
|
141
|
+
export async function submitInitialPrompt(connect, prompt, workspaceKey, signal, app = 'claude', write) {
|
|
142
|
+
const bounded = AbortSignal.any([signal, AbortSignal.timeout(20000)]);
|
|
143
|
+
let connection;
|
|
144
|
+
let sent = false, pasted = false;
|
|
145
|
+
try {
|
|
146
|
+
connection = await connect(bounded);
|
|
147
|
+
let tail = '', pasteEnabled = false;
|
|
148
|
+
const cursor = new PtyOutputCursor();
|
|
149
|
+
for await (const message of connection.messages()) {
|
|
150
|
+
if (bounded.aborted)
|
|
151
|
+
throw bounded.reason;
|
|
152
|
+
if (message.type === 'exit')
|
|
153
|
+
throw new Error('The agent exited before accepting a prompt.');
|
|
154
|
+
const bytes = cursor.consume(message);
|
|
155
|
+
if (!bytes)
|
|
156
|
+
continue;
|
|
157
|
+
tail = (tail + bytes.toString('utf8')).slice(-32768);
|
|
158
|
+
pasteEnabled ||= tail.includes('\x1b[?2004h');
|
|
159
|
+
// eslint-disable-next-line no-control-regex -- Strip native terminal ANSI before matching prompt echo.
|
|
160
|
+
const plain = tail.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/\s/g, '');
|
|
161
|
+
if (sent && bytes.length)
|
|
162
|
+
return;
|
|
163
|
+
if (pasted) {
|
|
164
|
+
// A long prompt overflows the input box: a 24-row terminal shows only its end, so either end confirms the paste.
|
|
165
|
+
const compact = prompt.replace(/\s/g, '');
|
|
166
|
+
if (!plain.includes(compact.slice(0, 32)) && !plain.includes(compact.slice(-32)) && !/Pasted(?:text|content)/i.test(plain))
|
|
167
|
+
continue;
|
|
168
|
+
const enter = Buffer.from('\r');
|
|
169
|
+
if (write)
|
|
170
|
+
await write(enter);
|
|
171
|
+
else
|
|
172
|
+
connection.input(enter);
|
|
173
|
+
sent = true;
|
|
174
|
+
tail = '';
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const ready = app === 'claude' ? tail.includes('❯') : tail.includes('›');
|
|
178
|
+
if (!ready || !pasteEnabled)
|
|
179
|
+
continue;
|
|
180
|
+
const paste = Buffer.from(`\x1b[200~${prompt}\x1b[201~`);
|
|
181
|
+
if (write)
|
|
182
|
+
await write(paste);
|
|
183
|
+
else
|
|
184
|
+
connection.input(paste);
|
|
185
|
+
pasted = true;
|
|
186
|
+
tail = '';
|
|
187
|
+
}
|
|
188
|
+
throw new Error('The agent terminal closed before accepting a prompt.');
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
throw new CodedError('initial_prompt_failed', `Created ${workspaceKey}, but the initial prompt ${sent ? 'could not be confirmed' : 'was not submitted'}. Open that conversation before starting another.`, undefined, { workspace_key: workspaceKey, submitted: sent });
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
await connection?.close();
|
|
195
|
+
}
|
|
196
|
+
}
|
package/dist/legal.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Terms of Service notice and acceptance, one place for every sign-in and sign-up path (the dashboard's sign-up
|
|
3
|
+
* page and the Mac app show the same line).
|
|
4
|
+
*
|
|
5
|
+
* - Signing in to an existing account (a password, the browser) only shows the notice: one line on stderr before it
|
|
6
|
+
* acts, or `terms_url` and `privacy_url` in the `--json` output for an app or agent to show.
|
|
7
|
+
* - A command that can create an account (`shard signup`, `shard auth register`, `shard auth login --codex`) needs the
|
|
8
|
+
* agreement first: `--accept-terms` (or SHARDFLUX_ACCEPT_TERMS=1), or "Continue? [Y/n]" on a terminal. Without
|
|
9
|
+
* either it stops before any network call with `terms_not_accepted` (exit 2), so an agent asks its person and
|
|
10
|
+
* re-runs with the flag. The API records the agreement on the new account (`accept_terms`, `terms_version`).
|
|
11
|
+
*/
|
|
12
|
+
import type { Ctx } from './commands.js';
|
|
13
|
+
import type { Values } from './args.js';
|
|
14
|
+
export declare const TERMS_URL = "https://shardflux.dev/terms";
|
|
15
|
+
export declare const PRIVACY_URL = "https://shardflux.dev/privacy";
|
|
16
|
+
/** The published version of the terms: the effective date on the terms page. */
|
|
17
|
+
export declare const TERMS_VERSION = "2026-09-28";
|
|
18
|
+
export declare const ACCEPT_TERMS_FLAG = "--accept-terms";
|
|
19
|
+
export declare const ACCEPT_TERMS_ENV = "SHARDFLUX_ACCEPT_TERMS";
|
|
20
|
+
/** "By continuing, you agree to our Terms of Service (<url>) and acknowledge our Privacy Policy (<url>)." */
|
|
21
|
+
export declare function legalNotice(): string;
|
|
22
|
+
/** The two links in a command's `--json` output. */
|
|
23
|
+
export declare function legalFields(): {
|
|
24
|
+
terms_url: string;
|
|
25
|
+
privacy_url: string;
|
|
26
|
+
};
|
|
27
|
+
/** The notice before a sign-in acts: a line on stderr (none in `--json`, where the output carries the links). */
|
|
28
|
+
export declare function showLegalNotice(ctx: Ctx): void;
|
|
29
|
+
/**
|
|
30
|
+
* The agreement before a command that can create an account, before any network call. Returns what the API records;
|
|
31
|
+
* throws TermsNotAcceptedError when there is none (no flag and no terminal to ask on, or the person said no).
|
|
32
|
+
*/
|
|
33
|
+
export declare function requireTermsAcceptance(ctx: Ctx, values: Values): Promise<{
|
|
34
|
+
acceptTerms: true;
|
|
35
|
+
termsVersion: string;
|
|
36
|
+
}>;
|
package/dist/legal.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { TermsNotAcceptedError } from "./errors.js";
|
|
2
|
+
export const TERMS_URL = 'https://shardflux.dev/terms';
|
|
3
|
+
export const PRIVACY_URL = 'https://shardflux.dev/privacy';
|
|
4
|
+
/** The published version of the terms: the effective date on the terms page. */
|
|
5
|
+
export const TERMS_VERSION = '2026-09-28';
|
|
6
|
+
export const ACCEPT_TERMS_FLAG = '--accept-terms';
|
|
7
|
+
export const ACCEPT_TERMS_ENV = 'SHARDFLUX_ACCEPT_TERMS';
|
|
8
|
+
/** "By continuing, you agree to our Terms of Service (<url>) and acknowledge our Privacy Policy (<url>)." */
|
|
9
|
+
export function legalNotice() {
|
|
10
|
+
return `By continuing, you agree to our Terms of Service (${TERMS_URL}) and acknowledge our Privacy Policy (${PRIVACY_URL}).`;
|
|
11
|
+
}
|
|
12
|
+
/** The two links in a command's `--json` output. */
|
|
13
|
+
export function legalFields() {
|
|
14
|
+
return { terms_url: TERMS_URL, privacy_url: PRIVACY_URL };
|
|
15
|
+
}
|
|
16
|
+
/** The notice before a sign-in acts: a line on stderr (none in `--json`, where the output carries the links). */
|
|
17
|
+
export function showLegalNotice(ctx) {
|
|
18
|
+
if (!ctx.json)
|
|
19
|
+
ctx.io.stderr.write(`${legalNotice()}\n`);
|
|
20
|
+
}
|
|
21
|
+
function acceptedUpFront(ctx, values) {
|
|
22
|
+
if (values['accept-terms'] === true)
|
|
23
|
+
return true;
|
|
24
|
+
const env = ctx.io.env[ACCEPT_TERMS_ENV]?.trim().toLowerCase();
|
|
25
|
+
return env === '1' || env === 'true' || env === 'yes';
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The agreement before a command that can create an account, before any network call. Returns what the API records;
|
|
29
|
+
* throws TermsNotAcceptedError when there is none (no flag and no terminal to ask on, or the person said no).
|
|
30
|
+
*/
|
|
31
|
+
export async function requireTermsAcceptance(ctx, values) {
|
|
32
|
+
const accepted = { acceptTerms: true, termsVersion: TERMS_VERSION };
|
|
33
|
+
if (acceptedUpFront(ctx, values)) {
|
|
34
|
+
showLegalNotice(ctx);
|
|
35
|
+
return accepted;
|
|
36
|
+
}
|
|
37
|
+
if (!ctx.input.interactive || ctx.json)
|
|
38
|
+
throw new TermsNotAcceptedError();
|
|
39
|
+
ctx.io.stderr.write(`${legalNotice()}\n`);
|
|
40
|
+
const answer = (await ctx.input.answer('Continue? [Y/n] '))?.trim().toLowerCase() ?? '';
|
|
41
|
+
if (answer === '' || answer === 'y' || answer === 'yes')
|
|
42
|
+
return accepted;
|
|
43
|
+
throw new TermsNotAcceptedError(true);
|
|
44
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -18,6 +18,7 @@ import { commandCenter } from "./command-center.js";
|
|
|
18
18
|
import { launchConversationProject } from "./conversation-terminal.js";
|
|
19
19
|
import { runAcp } from "./acp.js";
|
|
20
20
|
import { registerInstalledT3 } from "./t3-provider.js";
|
|
21
|
+
import { runAcpLogin } from "./acp-login.js";
|
|
21
22
|
import { runDeviceHelper } from "./device-helper.js";
|
|
22
23
|
import { housekeeping, startupHousekeeping } from "./agent-git-commands.js";
|
|
23
24
|
import { CredentialStore, configDir } from "./credentials.js";
|
|
@@ -84,6 +85,9 @@ function hintFor(info, cred, file, command = '') {
|
|
|
84
85
|
return STEP_UP_HINT;
|
|
85
86
|
if (info.code === 'mfa_required')
|
|
86
87
|
return 'finish signing in with the code of your authenticator app: shard auth mfa --code <code>';
|
|
88
|
+
// The browser sign-in (0.15.0+): refused in the browser, or not approved within its 10 minutes.
|
|
89
|
+
if (info.code === 'access_denied' || info.code === 'expired_token')
|
|
90
|
+
return 'start the sign-in again: shard auth login --browser';
|
|
87
91
|
if (info.code === 'email_unverified')
|
|
88
92
|
return `verify your email first: shard auth verify-email '<link from the email>' (a new link: shard auth resend-verification)`;
|
|
89
93
|
if (info.code === 'conflict' && reasonOf(info) === 'subscription_exists')
|
|
@@ -316,7 +320,7 @@ const FEEDBACK_EMAIL = 'shardflux@heliosone.fi';
|
|
|
316
320
|
*/
|
|
317
321
|
// allowance_exhausted (0.5.1): a used-up allowance, a reached spend cap or a paused overage is a billing state whose hint
|
|
318
322
|
// names the next step (upgrade, overage, the cap, the payment method), not a problem to report.
|
|
319
|
-
const NO_NUDGE_CODES = new Set(['usage', 'interrupted', 'unauthenticated', 'validation_failed', 'mfa_required', 'step_up_required', 'email_unverified', 'timeout', 'checkout_ended', 'allowance_exhausted']);
|
|
323
|
+
const NO_NUDGE_CODES = new Set(['usage', 'terms_not_accepted', 'interrupted', 'unauthenticated', 'validation_failed', 'mfa_required', 'step_up_required', 'email_unverified', 'timeout', 'checkout_ended', 'allowance_exhausted']);
|
|
320
324
|
const NO_NUDGE_REASONS = new Set([
|
|
321
325
|
'revision_mismatch',
|
|
322
326
|
'tree_revision_mismatch',
|
|
@@ -644,7 +648,7 @@ export async function run(argv, io, opts = {}) {
|
|
|
644
648
|
catch (err) {
|
|
645
649
|
return report(err);
|
|
646
650
|
}
|
|
647
|
-
const handler = parsed.command.path[0] === 'acp' ? (ctx) => runAcp(ctx) : parsed.command.path[0] === 'device-helper' ? runDeviceHelper : parsed.command === PROJECT_COMMAND ? launchConversationProject : parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
|
|
651
|
+
const handler = parsed.command.path[0] === 'acp' ? (ctx) => parsed.values.login === true ? runAcpLogin(ctx) : runAcp(ctx) : parsed.command.path[0] === 'device-helper' ? runDeviceHelper : parsed.command === PROJECT_COMMAND ? launchConversationProject : parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
|
|
648
652
|
if (!handler)
|
|
649
653
|
return report(new UsageError(`unknown command "${parsed.command.path.join(' ')}"`));
|
|
650
654
|
if (!['acp', 'device-helper'].includes(parsed.command.path[0]))
|
package/dist/menubar.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { Handler } from './commands.js';
|
|
2
2
|
export type NativeRunner = (tool: string, args: string[]) => Promise<string>;
|
|
3
|
+
/**
|
|
4
|
+
* Every native tool a default MenubarManager runs (plutil, codesign, xattr, open, the app's --control) goes through
|
|
5
|
+
* `nativeTools.run`. The CLI test support replaces it, so a test run never starts, stops or inspects a real app,
|
|
6
|
+
* whether or not a built Shardflux.app is in the package.
|
|
7
|
+
*/
|
|
8
|
+
export declare const nativeTools: {
|
|
9
|
+
run: (tool: string, args: string[], signal?: AbortSignal) => Promise<string>;
|
|
10
|
+
};
|
|
3
11
|
export interface MenubarStatus {
|
|
4
12
|
installed: boolean;
|
|
5
13
|
version: string | null;
|
package/dist/menubar.js
CHANGED
|
@@ -8,6 +8,14 @@ import { AgentStateStore } from "./agent-state.js";
|
|
|
8
8
|
import { UsageError } from "./errors.js";
|
|
9
9
|
import { json } from "./format.js";
|
|
10
10
|
const execute = promisify(execFile);
|
|
11
|
+
/**
|
|
12
|
+
* Every native tool a default MenubarManager runs (plutil, codesign, xattr, open, the app's --control) goes through
|
|
13
|
+
* `nativeTools.run`. The CLI test support replaces it, so a test run never starts, stops or inspects a real app,
|
|
14
|
+
* whether or not a built Shardflux.app is in the package.
|
|
15
|
+
*/
|
|
16
|
+
export const nativeTools = {
|
|
17
|
+
run: async (tool, args, signal) => (await execute(tool, args, { timeout: 15000, maxBuffer: 1024 * 1024, signal })).stdout,
|
|
18
|
+
};
|
|
11
19
|
const BUNDLE_ID = 'dev.shardflux.menubar';
|
|
12
20
|
export function compareBundleVersions(left, right) {
|
|
13
21
|
if (![left, right].every(v => /^\d+(\.\d+){0,3}$/.test(v)))
|
|
@@ -58,7 +66,7 @@ export class MenubarManager {
|
|
|
58
66
|
constructor(options = {}) {
|
|
59
67
|
this.app = join(options.home ?? homedir(), 'Applications', 'Shardflux.app');
|
|
60
68
|
this.#platform = options.platform ?? process.platform;
|
|
61
|
-
this.#run = options.run ?? (
|
|
69
|
+
this.#run = options.run ?? ((tool, args) => nativeTools.run(tool, args, options.signal));
|
|
62
70
|
this.#packageApp = options.packageApp ?? (() => {
|
|
63
71
|
try {
|
|
64
72
|
return join(dirname(createRequire(import.meta.url).resolve('@shardflux/menubar-darwin/package.json')), 'Shardflux.app');
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Daylight
|
|
2
|
+
|
|
3
|
+
A small task list: `server.mjs` (a Node.js server with a JSON API) and `index.html`. No dependencies.
|
|
4
|
+
|
|
5
|
+
## Run it
|
|
6
|
+
|
|
7
|
+
- `npm run dev` serves it on port 3000 and restarts on save. It watches files with inotifywait from inotify-tools, a
|
|
8
|
+
system package: install it with `sudo apt-get install -y inotify-tools` if it is missing.
|
|
9
|
+
- Open `http://localhost:3000` in the browser on your desktop (`DISPLAY=:1 setsid -f chromium http://localhost:3000`)
|
|
10
|
+
and use the computer tool to see and click it.
|
|
11
|
+
|
|
12
|
+
## Test it
|
|
13
|
+
|
|
14
|
+
Use it like a person: add a task, tick it off, and look at each view (All, Active, Completed). Leave the page open.
|
|
15
|
+
|
|
16
|
+
## Report
|
|
17
|
+
|
|
18
|
+
Reply with one short paragraph: what you started, what you clicked, and what works and what does not. Change code only
|
|
19
|
+
if you are asked to.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Daylight
|
|
2
|
+
|
|
3
|
+
A small task list: `server.mjs` (a Node.js server with a JSON API) and `index.html`. No dependencies.
|
|
4
|
+
|
|
5
|
+
## Run it
|
|
6
|
+
|
|
7
|
+
- `npm run dev` serves it on port 3000 and restarts on save. It watches files with inotifywait from inotify-tools, a
|
|
8
|
+
system package: install it with `sudo apt-get install -y inotify-tools` if it is missing.
|
|
9
|
+
- Open `http://localhost:3000` in the browser on your desktop (`DISPLAY=:1 setsid -f chromium http://localhost:3000`)
|
|
10
|
+
and use the computer tool to see and click it.
|
|
11
|
+
|
|
12
|
+
## Test it
|
|
13
|
+
|
|
14
|
+
Use it like a person: add a task, tick it off, and look at each view (All, Active, Completed). Leave the page open.
|
|
15
|
+
|
|
16
|
+
## Report
|
|
17
|
+
|
|
18
|
+
Reply with one short paragraph: what you started, what you clicked, and what works and what does not. Change code only
|
|
19
|
+
if you are asked to.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Daylight
|
|
2
|
+
|
|
3
|
+
A small full-stack task list: a Node.js server with a JSON API and one HTML page. No dependencies.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm run dev # serves on :3000 and reloads on save; needs inotify-tools (sudo apt-get install -y inotify-tools)
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Open `http://localhost:3000`. Tasks live in memory while the server runs. Set `PORT` to use another port.
|
|
10
|
+
|
|
11
|
+
Shardflux uses Daylight for its first-run demo, "Daylight (sample)": an agent installs what it needs, starts it and
|
|
12
|
+
tests it in the browser on its own computer. `CLAUDE.md` and `AGENTS.md` tell Claude Code and Codex how.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Dev server: runs server.mjs and restarts it when a source file is saved. Watches with inotifywait (inotify-tools).
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
if (spawnSync('inotifywait', ['--help'], { stdio: 'ignore' }).error) {
|
|
5
|
+
console.error('npm run dev watches files with inotifywait. Install it with: sudo apt-get install -y inotify-tools');
|
|
6
|
+
process.exit(1);
|
|
7
|
+
}
|
|
8
|
+
let server;
|
|
9
|
+
const start = () => { server = spawn(process.execPath, ['server.mjs'], { stdio: 'inherit' }); };
|
|
10
|
+
const restart = () => { console.log('Saved. Restarting Daylight…'); server.once('exit', start); server.kill(); };
|
|
11
|
+
start();
|
|
12
|
+
const watcher = spawn('inotifywait', ['-m', '-q', '-e', 'close_write,moved_to', '--format', '%f', '.'], { stdio: ['ignore', 'pipe', 'inherit'] });
|
|
13
|
+
let timer;
|
|
14
|
+
watcher.stdout.on('data', chunk => {
|
|
15
|
+
if (!/\.(mjs|html)\b/.test(String(chunk))) return;
|
|
16
|
+
clearTimeout(timer); timer = setTimeout(restart, 150);
|
|
17
|
+
});
|
|
18
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => { watcher.kill(); server.kill(); process.exit(0); });
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<meta charset="utf-8">
|
|
4
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
5
|
+
<title>Daylight · One good day</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root{color-scheme:light;--paper:#f5f2ea;--ink:#282b27;--muted:#747970;--line:#deded3;--green:#3f614e;font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink)}main{max-width:660px;margin:12vh auto;padding:24px}header{display:flex;align-items:center;justify-content:space-between}.wordmark{font-size:15px;font-weight:600;letter-spacing:-.2px}.dot{width:28px;height:28px;border:1px solid var(--green);border-radius:50%;display:inline-block;margin-right:12px;vertical-align:middle}.edition{font-size:12px;color:var(--muted)}h1{font-size:48px;line-height:1.05;font-weight:650;letter-spacing:-1.8px;margin:52px 0 14px}p{color:var(--muted);line-height:1.6}form{display:flex;gap:10px;margin:34px 0 32px}input[type=text]{flex:1;min-width:0;border:1px solid var(--line);border-radius:8px;padding:14px;background:#fffdf8;color:var(--ink);font:inherit}button{cursor:pointer;font:inherit;border:0;border-radius:7px;padding:11px 16px;background:var(--green);color:white}nav{display:flex;gap:6px;border-bottom:1px solid var(--line);padding-bottom:14px}nav button{background:transparent;color:var(--muted);font-size:13px;padding:8px 13px}nav button[aria-pressed=true]{background:#e1e7dc;color:var(--green)}ul{list-style:none;margin:0;padding:0;min-height:170px}li{display:flex;align-items:center;gap:14px;padding:21px 2px;border-bottom:1px solid var(--line)}li input{accent-color:var(--green);width:20px;height:20px;cursor:pointer}li label{flex:1}li.done label{text-decoration:line-through;color:var(--muted)}footer{display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-top:24px}.empty{padding:35px 0;color:var(--muted)}#error{color:#a13b32;min-height:18px;font-size:13px}button:focus-visible,input:focus-visible{outline:2px solid var(--green);outline-offset:3px}@media(max-width:600px){main{margin:5vh auto}h1{font-size:44px}.edition{display:none}}
|
|
8
|
+
</style>
|
|
9
|
+
<main>
|
|
10
|
+
<header><span class="wordmark"><span class="dot"></span>Daylight</span><span class="edition">A little room to get things done.</span></header>
|
|
11
|
+
<h1>One thing at a time.</h1>
|
|
12
|
+
<p>A short list. A clear head. Make room for a good day.</p>
|
|
13
|
+
<form id="add"><input type="text" id="title" aria-label="New task" placeholder="What would you like to do?" maxlength="100" required><button>Add task</button></form>
|
|
14
|
+
<nav aria-label="Task filters"><button type="button" data-view="all" aria-pressed="true">All</button><button type="button" data-view="active" aria-pressed="false">Active</button><button type="button" data-view="done" aria-pressed="false">Completed</button></nav>
|
|
15
|
+
<ul id="tasks" aria-live="polite"></ul><div id="error" role="alert"></div>
|
|
16
|
+
<footer><span id="remaining"></span><span>Small steps count.</span></footer>
|
|
17
|
+
</main>
|
|
18
|
+
<script type="module">
|
|
19
|
+
let view='all';const list=document.querySelector('#tasks'),error=document.querySelector('#error');
|
|
20
|
+
async function api(path,options){const response=await fetch(path,options);if(!response.ok)throw Error(await response.text());return response.json()}
|
|
21
|
+
async function render(){try{const data=await api('/api/tasks?view='+view);list.replaceChildren();for(const task of data.tasks){const row=document.createElement('li');row.classList.toggle('done',task.done);const check=document.createElement('input');check.type='checkbox';check.checked=task.done;check.id='task-'+task.id;check.addEventListener('change',async()=>{try{await api('/api/tasks/'+task.id,{method:'PATCH'});await render()}catch(e){error.textContent=e.message}});const label=document.createElement('label');label.htmlFor=check.id;label.textContent=task.title;row.append(check,label);list.append(row)}if(!data.tasks.length){const empty=document.createElement('li');empty.className='empty';empty.textContent='Nothing here. Enjoy the space.';list.append(empty)}document.querySelector('#remaining').textContent=data.remaining+' left to do';error.textContent=''}catch(e){error.textContent=e.message}}
|
|
22
|
+
document.querySelector('#add').addEventListener('submit',async e=>{e.preventDefault();try{await api('/api/tasks',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:document.querySelector('#title').value})});document.querySelector('#title').value='';await render()}catch(e){error.textContent=e.message}});
|
|
23
|
+
for(const button of document.querySelectorAll('[data-view]'))button.addEventListener('click',()=>{view=button.dataset.view;for(const other of document.querySelectorAll('[data-view]'))other.setAttribute('aria-pressed',String(other===button));document.title=view==='all'?'Daylight · One good day':'Daylight · '+button.textContent;render()});render();
|
|
24
|
+
</script>
|
|
25
|
+
</html>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const tasks = [
|
|
6
|
+
{ id: 1, title: 'Sketch the idea', done: true },
|
|
7
|
+
{ id: 2, title: 'Build something useful', done: false },
|
|
8
|
+
{ id: 3, title: 'Take a walk', done: false },
|
|
9
|
+
];
|
|
10
|
+
let nextID = 4;
|
|
11
|
+
const server = createServer(async (req, res) => {
|
|
12
|
+
try {
|
|
13
|
+
const url = new URL(req.url, 'http://localhost');
|
|
14
|
+
if (url.pathname === '/api/tasks' && req.method === 'GET') {
|
|
15
|
+
const view = url.searchParams.get('view') || 'all';
|
|
16
|
+
const visible = view === 'active' ? tasks.filter(t => !t.done) : view === 'done' ? tasks.filter(t => !t.done) : tasks;
|
|
17
|
+
res.setHeader('Content-Type', 'application/json');
|
|
18
|
+
return res.end(JSON.stringify({ tasks: visible, remaining: tasks.filter(t => !t.done).length }));
|
|
19
|
+
}
|
|
20
|
+
if (url.pathname === '/api/tasks' && req.method === 'POST') {
|
|
21
|
+
let body = ''; for await (const part of req) { body += part; if (body.length > 4096) throw Error('Too much text'); }
|
|
22
|
+
const title = String(JSON.parse(body).title || '').trim();
|
|
23
|
+
if (!title || title.length > 100) { res.writeHead(400); return res.end('Use 1–100 characters.'); }
|
|
24
|
+
tasks.push({ id: nextID++, title, done: false }); res.writeHead(201); return res.end('{}');
|
|
25
|
+
}
|
|
26
|
+
const match = url.pathname.match(/^\/api\/tasks\/(\d+)$/);
|
|
27
|
+
if (match && req.method === 'PATCH') {
|
|
28
|
+
const task = tasks.find(t => t.id === Number(match[1]));
|
|
29
|
+
if (!task) { res.writeHead(404); return res.end('Task not found'); }
|
|
30
|
+
task.done = !task.done; return res.end('{}');
|
|
31
|
+
}
|
|
32
|
+
if (url.pathname === '/' && req.method === 'GET') {
|
|
33
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
34
|
+
return res.end(await readFile(new URL('./index.html', import.meta.url)));
|
|
35
|
+
}
|
|
36
|
+
res.writeHead(404); res.end('Not found');
|
|
37
|
+
} catch { res.writeHead(400); res.end('Could not read that request.'); }
|
|
38
|
+
});
|
|
39
|
+
const port = Number(process.env.PORT || 3000);
|
|
40
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) server.listen(port, '0.0.0.0', () => console.log(`Daylight is running on http://0.0.0.0:${port}`));
|
|
41
|
+
export { server };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { access, cp, mkdir, mkdtemp } from 'node:fs/promises';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
const execute = promisify(execFile);
|
|
7
|
+
export async function sampleSource() {
|
|
8
|
+
for (const url of [new URL('./onboarding-app/', import.meta.url), new URL('../../../examples/onboarding-app/', import.meta.url)]) {
|
|
9
|
+
try {
|
|
10
|
+
await access(new URL('server.mjs', url));
|
|
11
|
+
return fileURLToPath(url);
|
|
12
|
+
}
|
|
13
|
+
catch { /* source tree or published bundle */ }
|
|
14
|
+
}
|
|
15
|
+
throw new Error('The onboarding app is missing. Reinstall the Shardflux CLI.');
|
|
16
|
+
}
|
|
17
|
+
/** A private, fresh real Git project; never writes into the user's selected repository. */
|
|
18
|
+
export async function prepareOnboardingSample(directory) {
|
|
19
|
+
const root = join(directory, 'onboarding-projects');
|
|
20
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
21
|
+
// The folder's own name is what the launcher lists: "Daylight (sample)".
|
|
22
|
+
const path = join(await mkdtemp(join(root, 'sample-')), 'Daylight (sample)');
|
|
23
|
+
await cp(await sampleSource(), path, { recursive: true });
|
|
24
|
+
const git = (args) => execute('git', args, { cwd: path, timeout: 10000, env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' } });
|
|
25
|
+
await git(['init', '-q', '-b', 'main']);
|
|
26
|
+
await git(['add', '.']);
|
|
27
|
+
await git(['-c', 'user.name=Shardflux', '-c', 'user.email=first-run@shardflux.dev', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'Start Daylight onboarding app']);
|
|
28
|
+
return path;
|
|
29
|
+
}
|