@fourier-labs/harbour 0.1.13 → 0.1.14
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/dist/packages/harbour-cli/src/auth.js +27 -4
- package/dist/packages/harbour-cli/src/check.js +119 -0
- package/dist/packages/harbour-cli/src/cli.js +126 -7
- package/dist/packages/harbour-cli/src/config.js +12 -3
- package/dist/packages/harbour-cli/src/dev.js +74 -0
- package/dist/packages/harbour-cli/src/forwarder.js +147 -0
- package/dist/packages/harbour-cli/src/integrations.js +151 -0
- package/dist/packages/harbour-cli/src/kit-bundle.js +63 -0
- package/dist/packages/harbour-cli/src/kit.js +167 -0
- package/dist/packages/harbour-cli/src/local-runtime.js +355 -0
- package/dist/packages/harbour-cli/src/operations.js +2 -0
- package/dist/packages/harbour-cli/src/productionise.js +12 -2
- package/dist/packages/harbour-cli/src/remote-mcp-client.js +23 -11
- package/dist/packages/harbour-cli/src/starter.js +390 -0
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/dist/src/analyzer.js +19 -1
- package/dist/src/secret-paths.js +8 -0
- package/dist/src/source-intake.js +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { bundleDiff } from "./kit-bundle.js";
|
|
4
|
+
import { newKitLock, readKitLock, writeKitLock } from "./kit.js";
|
|
5
|
+
import { CliError } from "./output.js";
|
|
6
|
+
/**
|
|
7
|
+
* Creates the starter in an empty directory, or adds the missing kit files to
|
|
8
|
+
* an existing Vite + React app. User files are never overwritten: a path that
|
|
9
|
+
* exists is reported as kept. Instruction files get a managed block appended.
|
|
10
|
+
*/
|
|
11
|
+
export async function initKit(root, bundle, options = {}) {
|
|
12
|
+
await mkdir(root, { recursive: true });
|
|
13
|
+
const existingPackage = await readJson(join(root, "package.json"));
|
|
14
|
+
const emptyDir = !existingPackage && !(await exists(join(root, "src")));
|
|
15
|
+
if (existingPackage && !isSupportedApp(existingPackage))
|
|
16
|
+
throw new CliError("APP_UNSUPPORTED", "harbour init supports an empty directory or an existing Vite + React app (package.json must depend on vite and react).");
|
|
17
|
+
const result = { root, created: [], kept: [], updated: [], mode: options.upgrade ? "upgrade" : emptyDir ? "starter" : "existing", bundleChanges: [] };
|
|
18
|
+
const write = async (path, content) => {
|
|
19
|
+
const absolute = join(root, path);
|
|
20
|
+
if (await exists(absolute)) {
|
|
21
|
+
result.kept.push(path);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
await mkdir(dirname(absolute), { recursive: true });
|
|
25
|
+
await writeFile(absolute, content);
|
|
26
|
+
result.created.push(path);
|
|
27
|
+
};
|
|
28
|
+
const appFiles = emptyDir ? starterFiles(bundle) : {};
|
|
29
|
+
for (const [path, content] of Object.entries({ ...appFiles, ...kitFiles() }))
|
|
30
|
+
await write(path, content);
|
|
31
|
+
await appendManaged(root, ".gitignore", GITIGNORE_LINES, result, "\n");
|
|
32
|
+
for (const file of ["CLAUDE.md", "AGENTS.md"])
|
|
33
|
+
await appendManaged(root, file, managedBlock(), result, "\n\n", MANAGED_START, MANAGED_END);
|
|
34
|
+
await write(".claude/skills/harbour-kit/SKILL.md", SKILL_FILE);
|
|
35
|
+
const previous = await readKitLock(root);
|
|
36
|
+
if (!previous) {
|
|
37
|
+
await writeKitLock(root, newKitLock(bundle, options.tenantId ?? ""));
|
|
38
|
+
result.created.push(".harbour/kit.lock.json");
|
|
39
|
+
}
|
|
40
|
+
else if (options.upgrade) {
|
|
41
|
+
result.bundleChanges = bundleDiff(previous.bundle, bundle);
|
|
42
|
+
if (result.bundleChanges.length) {
|
|
43
|
+
await writeKitLock(root, { ...previous, bundle });
|
|
44
|
+
result.updated.push(".harbour/kit.lock.json");
|
|
45
|
+
}
|
|
46
|
+
else
|
|
47
|
+
result.kept.push(".harbour/kit.lock.json");
|
|
48
|
+
}
|
|
49
|
+
else
|
|
50
|
+
result.kept.push(".harbour/kit.lock.json");
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
function isSupportedApp(pkg) {
|
|
54
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
55
|
+
return Boolean(deps.vite && deps.react);
|
|
56
|
+
}
|
|
57
|
+
async function appendManaged(root, file, block, result, separator, start, end) {
|
|
58
|
+
const absolute = join(root, file);
|
|
59
|
+
const current = await readFile(absolute, "utf8").catch(() => undefined);
|
|
60
|
+
if (current === undefined) {
|
|
61
|
+
await writeFile(absolute, `${block}\n`);
|
|
62
|
+
result.created.push(file);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (start && end && current.includes(start) && current.includes(end)) {
|
|
66
|
+
const next = current.replace(new RegExp(`${escape(start)}[\\s\\S]*?${escape(end)}`), block);
|
|
67
|
+
if (next !== current) {
|
|
68
|
+
await writeFile(absolute, next);
|
|
69
|
+
result.updated.push(file);
|
|
70
|
+
}
|
|
71
|
+
else
|
|
72
|
+
result.kept.push(file);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!start && block.split("\n").every(line => current.split("\n").includes(line))) {
|
|
76
|
+
result.kept.push(file);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
await writeFile(absolute, `${current.replace(/\n*$/, "")}${separator}${block}\n`);
|
|
80
|
+
result.updated.push(file);
|
|
81
|
+
}
|
|
82
|
+
const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
83
|
+
const exists = (path) => stat(path).then(() => true, () => false);
|
|
84
|
+
const readJson = (path) => readFile(path, "utf8").then(text => JSON.parse(text), () => undefined);
|
|
85
|
+
// ---- Templates -----------------------------------------------------------------
|
|
86
|
+
export const MANAGED_START = "<!-- harbour:kit:start -->";
|
|
87
|
+
export const MANAGED_END = "<!-- harbour:kit:end -->";
|
|
88
|
+
const GITIGNORE_LINES = ["node_modules/", "dist/", ".harbour/local/"].join("\n");
|
|
89
|
+
/** Condensed from the pipeline briefs (browser-sdk-to-harbour, route-auth-policy, auth-local-to-harbour-sso) for a kit app. */
|
|
90
|
+
export function managedBlock() {
|
|
91
|
+
return [
|
|
92
|
+
MANAGED_START,
|
|
93
|
+
"## Harbour development kit",
|
|
94
|
+
"",
|
|
95
|
+
"This app runs on Harbour. Keep these rules; `harbour check` and the deployment pipeline enforce them.",
|
|
96
|
+
"",
|
|
97
|
+
"- Identity, data and files go through `@harbour/app-sdk` only: `harbour.identity.current()`, `harbour.data.from(table)`, `harbour.files.*`. Never open a database, storage bucket or company system from browser code, and never `fetch` a company URL directly.",
|
|
98
|
+
"- Company systems (Slack, Gmail, warehouse views) are reached only through `harbour.integrations.execute(connection, {operation, resource, input})` with the connection and operation declared in `.harbour/integrations.json`. Operations are a closed set: `slack.channel.history` (user identity), `slack.message.post` (app identity), `gmail.thread.list` and `gmail.message.read` (user identity, read-only, resource `inbox`), `warehouse.view.read` (app identity). Resources are logical names, never IDs, URLs or tokens.",
|
|
99
|
+
"- A Slack message is sent only after the person presses an explicit Send control; pass a fresh UUID `idempotencyKey` per send action and reuse the same key when retrying that action. Never send from an effect, a timer or a background queue, and never send during checks.",
|
|
100
|
+
"- Consent is a user action: call `harbour.integrations.connect(connection)` and, when it returns `consent_required`, open `authorizationUrl`. Missing consent never falls back to another account.",
|
|
101
|
+
"- Authentication is owned by Harbour SSO. Do not add login forms, JWT handling, or trust a role, owner id or tenant id supplied by the browser. Row ownership is decided in SQL through `current_setting('harbour.user_id', true)` and `current_setting('harbour.user_email', true)`.",
|
|
102
|
+
"- Every route needs a signed-in human by default; do not add public routes or wildcard exceptions to make something work.",
|
|
103
|
+
"- No secrets, tokens, `.env` values or fetched company content in source. `.harbour/local/` is ignored and never committed; `.harbour/integrations.json` and `.harbour/kit.lock.json` are committed.",
|
|
104
|
+
"- Schema changes are SQL files in `migrations/`, applied by `harbour dev` and `harbour check`. Grant browser-path table privileges to `harbour_app_gateway`.",
|
|
105
|
+
"- Commands: `harbour dev --app-root .` (local runtime), `harbour check --app-root .` (declaration, types, build, migrations, journeys), `harbour integrations request <connection> --reason <text> --app-root .`, `harbour integrations status --app-root .`, `harbour productionise --app-root .`. Company calls in `dev` use the account from `harbour login`; the local fixture user is only the app's identity.",
|
|
106
|
+
"- Codex reads this AGENTS.md block; Claude Code also reads `.claude/skills/harbour-kit/SKILL.md`.",
|
|
107
|
+
MANAGED_END
|
|
108
|
+
].join("\n");
|
|
109
|
+
}
|
|
110
|
+
const SKILL_FILE = `---
|
|
111
|
+
name: harbour-kit
|
|
112
|
+
description: Build, run and check this Harbour app with the Harbour CLI (dev, check, integrations, productionise).
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
Follow the "Harbour development kit" block in CLAUDE.md / AGENTS.md. Workflow:
|
|
116
|
+
|
|
117
|
+
1. \`harbour dev --app-root .\` starts Postgres, storage, the identity fixture, the app gateway and Vite behind one loopback origin printed in the banner.
|
|
118
|
+
2. Edit \`src/\`, \`migrations/\` and \`.harbour/integrations.json\`. Use the SDK only; declare integrations before calling them.
|
|
119
|
+
3. \`harbour check --app-root .\` before every hand-off; read \`.harbour/local/check-report.json\`. Real integrations are reported as not tested unless \`--integrations\` is passed (read operations only).
|
|
120
|
+
4. \`harbour integrations request <connection> --reason "<why>" --app-root .\` asks IT for development access; pending is not ready.
|
|
121
|
+
5. \`harbour productionise --app-root .\` saves and deploys the preview; \`harbour promote\` after the person has tested it.
|
|
122
|
+
`;
|
|
123
|
+
function kitFiles() {
|
|
124
|
+
return {
|
|
125
|
+
".harbour/integrations.json": `${JSON.stringify({
|
|
126
|
+
schema: "harbour.app-integrations/2.0",
|
|
127
|
+
connections: {
|
|
128
|
+
// Only connections the app really calls belong here: productionise
|
|
129
|
+
// refuses to deploy until every declared one has a preview grant.
|
|
130
|
+
"company-slack": { kind: "saas", operations: { "slack.channel.history": { identity: "user", resources: ["team-updates"] }, "slack.message.post": { identity: "app", resources: ["team-updates"] } } }
|
|
131
|
+
}
|
|
132
|
+
}, null, 2)}\n`,
|
|
133
|
+
"migrations/0001_notes.sql": MIGRATION,
|
|
134
|
+
".harbour/checks/notes-journey.mjs": JOURNEY_CHECK
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function starterFiles(bundle) {
|
|
138
|
+
return {
|
|
139
|
+
"package.json": `${JSON.stringify({
|
|
140
|
+
name: "harbour-app", private: true, version: "0.1.0", type: "module",
|
|
141
|
+
scripts: { dev: "vite", build: "tsc --noEmit && vite build", preview: "vite preview" },
|
|
142
|
+
// The SDK is distributed as a tarball in the kit bundle, not from the public registry:
|
|
143
|
+
// `harbour dev` installs it from HARBOUR_KIT_SDK_TARBALL or the manifest's sdk.url and
|
|
144
|
+
// verifies its sha256 against .harbour/kit.lock.json before use.
|
|
145
|
+
dependencies: { [bundle.sdk.package]: bundle.sdk.version ?? "1.0.0", react: "^18.3.1", "react-dom": "^18.3.1" },
|
|
146
|
+
devDependencies: { "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", typescript: "^5.6.3", vite: "^5.4.11" }
|
|
147
|
+
}, null, 2)}\n`,
|
|
148
|
+
"tsconfig.json": `${JSON.stringify({ compilerOptions: { target: "ES2022", lib: ["ES2022", "DOM", "DOM.Iterable"], module: "ESNext", moduleResolution: "Bundler", jsx: "react-jsx", strict: true, noEmit: true, skipLibCheck: true, isolatedModules: true }, include: ["src"] }, null, 2)}\n`,
|
|
149
|
+
"vite.config.ts": VITE_CONFIG,
|
|
150
|
+
"index.html": `<!doctype html>\n<html lang="en">\n <head>\n <meta charset="UTF-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <title>Harbour app</title>\n </head>\n <body>\n <div id="root"></div>\n <script type="module" src="/src/main.tsx"></script>\n </body>\n</html>\n`,
|
|
151
|
+
"src/vite-env.d.ts": "/// <reference types=\"vite/client\" />\n",
|
|
152
|
+
"src/main.tsx": `import { StrictMode } from "react";\nimport { createRoot } from "react-dom/client";\nimport { App } from "./App";\n\ncreateRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);\n`,
|
|
153
|
+
"src/harbour.client.ts": HARBOUR_CLIENT,
|
|
154
|
+
"src/App.tsx": APP,
|
|
155
|
+
"src/SlackPanel.tsx": SLACK_PANEL,
|
|
156
|
+
"README.md": "# Harbour app\n\nCreated by `harbour init`. Run `harbour dev --app-root .` and open the printed origin. See CLAUDE.md / AGENTS.md for the kit rules.\n\n`.harbour/integrations.json` declares `company-slack` only. Declare a connection only when the app calls it: `harbour productionise` refuses to deploy until every declared connection has a preview grant (`harbour integrations request <connection> --environment preview --reason \"<why>\" --app-root .`). The warehouse example (`sales-warehouse` / `warehouse.view.read`) is in a comment in `src/App.tsx`.\n"
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const VITE_CONFIG = `import { defineConfig } from "vite";
|
|
160
|
+
import react from "@vitejs/plugin-react";
|
|
161
|
+
|
|
162
|
+
// \`harbour dev\` fronts Vite with one loopback origin and sets HARBOUR_LOCAL_ORIGIN;
|
|
163
|
+
// the proxy below keeps SDK calls working if Vite is opened directly.
|
|
164
|
+
const harbourOrigin = process.env.HARBOUR_LOCAL_ORIGIN ?? "http://127.0.0.1:4180";
|
|
165
|
+
|
|
166
|
+
export default defineConfig({
|
|
167
|
+
plugins: [react()],
|
|
168
|
+
server: {
|
|
169
|
+
host: "127.0.0.1",
|
|
170
|
+
port: Number(process.env.HARBOUR_VITE_PORT ?? 5173),
|
|
171
|
+
strictPort: true,
|
|
172
|
+
proxy: { "/_harbour": { target: harbourOrigin, changeOrigin: false } }
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
`;
|
|
176
|
+
const HARBOUR_CLIENT = `import { createClient } from "@harbour/app-sdk";
|
|
177
|
+
|
|
178
|
+
// Single client for the whole app. Identity, data and files come from Harbour;
|
|
179
|
+
// the same calls work locally (harbour dev) and in preview/production.
|
|
180
|
+
export const harbour = createClient();
|
|
181
|
+
|
|
182
|
+
export type ConnectResult = { status: "connected"; accountLabel: string } | { status: "consent_required"; authorizationUrl: string };
|
|
183
|
+
export type IntegrationCall = { operation: string; resource: string; input: unknown; idempotencyKey?: string };
|
|
184
|
+
export type Integrations = {
|
|
185
|
+
connect(connection: string): Promise<ConnectResult>;
|
|
186
|
+
disconnect(connection: string): Promise<{ status: "disconnected" }>;
|
|
187
|
+
execute<T>(connection: string, call: IntegrationCall): Promise<T>;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** Typed access to the SDK's integrations surface (kit bundle SDK); throws a clear error on an older SDK. */
|
|
191
|
+
export function integrations(): Integrations {
|
|
192
|
+
const surface = (harbour as { integrations?: Integrations }).integrations;
|
|
193
|
+
if (!surface) throw new Error("This @harbour/app-sdk build has no integrations surface; run harbour init --upgrade.");
|
|
194
|
+
return surface;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function errorCode(error: unknown): string {
|
|
198
|
+
const details = (error as { details?: { code?: string } } | undefined)?.details;
|
|
199
|
+
return typeof details?.code === "string" ? details.code : (error as { category?: string })?.category ?? "UNKNOWN";
|
|
200
|
+
}
|
|
201
|
+
`;
|
|
202
|
+
const APP = `import { useCallback, useEffect, useState } from "react";
|
|
203
|
+
import type { HarbourUser } from "@harbour/app-sdk";
|
|
204
|
+
import { harbour } from "./harbour.client";
|
|
205
|
+
import { SlackPanel } from "./SlackPanel";
|
|
206
|
+
|
|
207
|
+
type Note = { id: number; title: string; done: boolean; created_at: string };
|
|
208
|
+
type StoredFile = { path?: string; name?: string; size?: number };
|
|
209
|
+
|
|
210
|
+
export function App() {
|
|
211
|
+
const [user, setUser] = useState<HarbourUser | null>(null);
|
|
212
|
+
const [notes, setNotes] = useState<Note[]>([]);
|
|
213
|
+
const [title, setTitle] = useState("");
|
|
214
|
+
const [files, setFiles] = useState<StoredFile[]>([]);
|
|
215
|
+
const [error, setError] = useState<string | null>(null);
|
|
216
|
+
|
|
217
|
+
const loadNotes = useCallback(async () => {
|
|
218
|
+
const { data } = await harbour.data.from<Note>("notes").select("*").order("created_at", { ascending: false });
|
|
219
|
+
setNotes(data);
|
|
220
|
+
}, []);
|
|
221
|
+
const loadFiles = useCallback(async () => { setFiles((await harbour.files.list("private/")) as StoredFile[]); }, []);
|
|
222
|
+
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
harbour.identity.current().then(setUser).catch(err => setError(String(err)));
|
|
225
|
+
loadNotes().catch(err => setError(String(err)));
|
|
226
|
+
loadFiles().catch(err => setError(String(err)));
|
|
227
|
+
return harbour.identity.onChange(setUser);
|
|
228
|
+
}, [loadNotes, loadFiles]);
|
|
229
|
+
|
|
230
|
+
const addNote = async () => {
|
|
231
|
+
if (!title.trim()) return;
|
|
232
|
+
await harbour.data.from("notes").insert({ title: title.trim() });
|
|
233
|
+
setTitle("");
|
|
234
|
+
await loadNotes();
|
|
235
|
+
};
|
|
236
|
+
const toggle = async (note: Note) => { await harbour.data.from("notes").update({ done: !note.done }).eq("id", note.id); await loadNotes(); };
|
|
237
|
+
const remove = async (note: Note) => { await harbour.data.from("notes").delete().eq("id", note.id); await loadNotes(); };
|
|
238
|
+
const upload = async (file: File) => { await harbour.files.upload(\`private/\${file.name}\`, file); await loadFiles(); };
|
|
239
|
+
|
|
240
|
+
// Warehouse example. Declare the connection in .harbour/integrations.json first —
|
|
241
|
+
// every declared connection needs a preview grant before productionise will deploy:
|
|
242
|
+
// "sales-warehouse": { "kind": "database", "operations": { "warehouse.view.read": { "identity": "app", "resources": { "weekly_sales": { "columns": ["week", "total"] } } } } }
|
|
243
|
+
// then request access (harbour integrations request sales-warehouse --reason "<why>" --app-root .) and uncomment:
|
|
244
|
+
// const report = await integrations().execute<{ rows: Array<{ week: string; total: number }> }>("sales-warehouse", {
|
|
245
|
+
// operation: "warehouse.view.read", resource: "weekly_sales", input: { columns: ["week", "total"], limit: 100 }
|
|
246
|
+
// });
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<main style={{ fontFamily: "system-ui", maxWidth: 720, margin: "2rem auto", padding: "0 1rem" }}>
|
|
250
|
+
<h1>Harbour starter</h1>
|
|
251
|
+
<p>Signed in as <strong>{user ? user.email : "…"}</strong> (identity from Harbour; locally the fixture user).</p>
|
|
252
|
+
{error && <p role="alert" style={{ color: "crimson" }}>{error}</p>}
|
|
253
|
+
|
|
254
|
+
<section>
|
|
255
|
+
<h2>Notes</h2>
|
|
256
|
+
<form onSubmit={event => { event.preventDefault(); void addNote(); }}>
|
|
257
|
+
<input value={title} onChange={event => setTitle(event.target.value)} placeholder="New note" aria-label="New note" />
|
|
258
|
+
<button type="submit">Add</button>
|
|
259
|
+
</form>
|
|
260
|
+
<ul>
|
|
261
|
+
{notes.map(note => (
|
|
262
|
+
<li key={note.id}>
|
|
263
|
+
<label><input type="checkbox" checked={note.done} onChange={() => void toggle(note)} /> {note.title}</label>
|
|
264
|
+
<button type="button" onClick={() => void remove(note)} aria-label={\`Delete \${note.title}\`}>×</button>
|
|
265
|
+
</li>
|
|
266
|
+
))}
|
|
267
|
+
</ul>
|
|
268
|
+
</section>
|
|
269
|
+
|
|
270
|
+
<section>
|
|
271
|
+
<h2>Private files</h2>
|
|
272
|
+
<input type="file" aria-label="Upload file" onChange={event => { const file = event.target.files?.[0]; if (file) void upload(file); }} />
|
|
273
|
+
<ul>{files.map((file, index) => <li key={index}>{file.path ?? file.name}{file.size !== undefined ? \` (\${file.size} bytes)\` : ""}</li>)}</ul>
|
|
274
|
+
</section>
|
|
275
|
+
|
|
276
|
+
<SlackPanel connection="company-slack" channel="team-updates" />
|
|
277
|
+
</main>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
`;
|
|
281
|
+
const SLACK_PANEL = `import { useState } from "react";
|
|
282
|
+
import { errorCode, integrations, type ConnectResult } from "./harbour.client";
|
|
283
|
+
|
|
284
|
+
type Message = { id: string; text: string; authorId: string; createdAt: string };
|
|
285
|
+
type SendState = { kind: "idle" } | { kind: "sending" } | { kind: "sent"; messageId: string } | { kind: "unknown" } | { kind: "rate_limited"; retryAfterSeconds?: number } | { kind: "failed"; code: string };
|
|
286
|
+
const SEND_KEY = "harbour.slack.sendActionId";
|
|
287
|
+
|
|
288
|
+
/** One channel: connect (consent), load history, draft, explicit Send with a stable idempotency key. */
|
|
289
|
+
export function SlackPanel({ connection, channel }: { connection: string; channel: string }) {
|
|
290
|
+
const [link, setLink] = useState<ConnectResult | { status: "reconnect_required" } | { status: "unknown" }>({ status: "unknown" });
|
|
291
|
+
const [history, setHistory] = useState<Message[]>([]);
|
|
292
|
+
const [draft, setDraft] = useState("");
|
|
293
|
+
const [send, setSend] = useState<SendState>({ kind: "idle" });
|
|
294
|
+
const [note, setNote] = useState<string | null>(null);
|
|
295
|
+
|
|
296
|
+
const connect = async () => {
|
|
297
|
+
try { setLink(await integrations().connect(connection)); setNote(null); }
|
|
298
|
+
catch (error) { setNote(\`Connect failed: \${errorCode(error)}\`); }
|
|
299
|
+
};
|
|
300
|
+
const loadHistory = async () => {
|
|
301
|
+
try {
|
|
302
|
+
const result = await integrations().execute<{ messages: Message[]; hasMore: boolean }>(connection, { operation: "slack.channel.history", resource: channel, input: { limit: 15 } });
|
|
303
|
+
setHistory(result.messages); setNote(null);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
const code = errorCode(error);
|
|
306
|
+
if (code === "USER_CONNECTION_REQUIRED") setLink({ status: "unknown" });
|
|
307
|
+
else if (code === "USER_RECONNECT_REQUIRED") setLink({ status: "reconnect_required" });
|
|
308
|
+
setNote(\`History unavailable: \${code}\`);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// The key is created when the person presses Send and survives retries of that
|
|
313
|
+
// same action (component state + sessionStorage); a new draft gets a new key.
|
|
314
|
+
const sendDraft = async () => {
|
|
315
|
+
if (!draft.trim() || send.kind === "sending") return;
|
|
316
|
+
let key = sessionStorage.getItem(SEND_KEY);
|
|
317
|
+
if (!key) { key = crypto.randomUUID(); sessionStorage.setItem(SEND_KEY, key); }
|
|
318
|
+
setSend({ kind: "sending" });
|
|
319
|
+
try {
|
|
320
|
+
const result = await integrations().execute<{ messageId: string }>(connection, { operation: "slack.message.post", resource: channel, input: { text: draft }, idempotencyKey: key });
|
|
321
|
+
sessionStorage.removeItem(SEND_KEY);
|
|
322
|
+
setSend({ kind: "sent", messageId: result.messageId }); setDraft("");
|
|
323
|
+
} catch (error) {
|
|
324
|
+
const code = errorCode(error);
|
|
325
|
+
const retryAfterSeconds = (error as { details?: { retryAfterSeconds?: number } })?.details?.retryAfterSeconds;
|
|
326
|
+
if (code === "SEND_OUTCOME_UNKNOWN") setSend({ kind: "unknown" });
|
|
327
|
+
else if (code === "PROVIDER_RATE_LIMITED" || code === "RATE_LIMITED") setSend({ kind: "rate_limited", retryAfterSeconds });
|
|
328
|
+
else setSend({ kind: "failed", code });
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
<section>
|
|
334
|
+
<h2>Slack: #{channel}</h2>
|
|
335
|
+
{link.status === "connected" && <p>Connected as {link.accountLabel}.</p>}
|
|
336
|
+
{link.status === "consent_required" && <p>Slack needs your consent: <a href={link.authorizationUrl}>connect your Slack account</a>.</p>}
|
|
337
|
+
{link.status === "reconnect_required" && <p>Your Slack connection expired. <button type="button" onClick={() => void connect()}>Reconnect</button></p>}
|
|
338
|
+
{link.status === "unknown" && <button type="button" onClick={() => void connect()}>Connect Slack</button>}
|
|
339
|
+
<p><button type="button" onClick={() => void loadHistory()}>Load history</button></p>
|
|
340
|
+
<ul>{history.map(message => <li key={message.id}><code>{message.authorId}</code> {message.text}</li>)}</ul>
|
|
341
|
+
<textarea value={draft} onChange={event => { setDraft(event.target.value); if (send.kind !== "sending") { sessionStorage.removeItem(SEND_KEY); setSend({ kind: "idle" }); } }} placeholder="Draft a message (nothing is sent until you press Send)" rows={3} style={{ width: "100%" }} />
|
|
342
|
+
<p><button type="button" onClick={() => void sendDraft()} disabled={send.kind === "sending" || !draft.trim()}>Send</button></p>
|
|
343
|
+
{send.kind === "sent" && <p>Sent (message {send.messageId}).</p>}
|
|
344
|
+
{send.kind === "unknown" && <p>Harbour could not confirm whether the message was sent. Retry sends the same action, not a duplicate. <button type="button" onClick={() => void sendDraft()}>Retry</button></p>}
|
|
345
|
+
{send.kind === "rate_limited" && <p>Slack is rate limiting; try again{send.retryAfterSeconds ? \` in \${send.retryAfterSeconds}s\` : " shortly"}.</p>}
|
|
346
|
+
{send.kind === "failed" && <p>Send failed: {send.code}.</p>}
|
|
347
|
+
{note && <p>{note}</p>}
|
|
348
|
+
</section>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
`;
|
|
352
|
+
const MIGRATION = `-- Notes table for the starter. Ownership comes from the gateway's transaction-local
|
|
353
|
+
-- settings (harbour.user_id / harbour.user_email); the browser never supplies it.
|
|
354
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
355
|
+
id BIGSERIAL PRIMARY KEY,
|
|
356
|
+
owner_subject TEXT NOT NULL DEFAULT current_setting('harbour.user_id', true),
|
|
357
|
+
title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 500),
|
|
358
|
+
done BOOLEAN NOT NULL DEFAULT FALSE,
|
|
359
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
360
|
+
);
|
|
361
|
+
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
|
|
362
|
+
DROP POLICY IF EXISTS notes_owner ON notes;
|
|
363
|
+
CREATE POLICY notes_owner ON notes
|
|
364
|
+
USING (owner_subject = current_setting('harbour.user_id', true))
|
|
365
|
+
WITH CHECK (owner_subject = current_setting('harbour.user_id', true));
|
|
366
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON notes TO harbour_app_gateway;
|
|
367
|
+
GRANT USAGE, SELECT ON SEQUENCE notes_id_seq TO harbour_app_gateway;
|
|
368
|
+
`;
|
|
369
|
+
const JOURNEY_CHECK = `// Journey check: the signed-in identity can create, read and delete a note through
|
|
370
|
+
// the running app (HARBOUR_APP_URL). Runs under \`harbour check\` while \`harbour dev\`
|
|
371
|
+
// is up, and in the deployment pipeline's retained-check container.
|
|
372
|
+
import assert from "node:assert/strict";
|
|
373
|
+
|
|
374
|
+
const appUrl = process.env.HARBOUR_APP_URL;
|
|
375
|
+
assert.ok(appUrl, "HARBOUR_APP_URL is required");
|
|
376
|
+
const sdk = await import(process.env.HARBOUR_SDK_MODULE ?? "@harbour/app-sdk");
|
|
377
|
+
const token = process.env.HARBOUR_IDENTITY_CONTEXT_TOKEN;
|
|
378
|
+
const fetchWithIdentity = (input, init = {}) => fetch(input, { ...init, headers: { ...(init.headers ?? {}), ...(token ? { "x-harbour-identity-context": token } : {}) } });
|
|
379
|
+
const harbour = sdk.createClient({ baseUrl: appUrl, fetch: fetchWithIdentity });
|
|
380
|
+
|
|
381
|
+
const user = await harbour.identity.current();
|
|
382
|
+
assert.ok(user && user.email, "identity/current must return the signed-in user");
|
|
383
|
+
const title = \`journey \${Date.now()}\`;
|
|
384
|
+
await harbour.data.from("notes").insert({ title });
|
|
385
|
+
const { data: notes } = await harbour.data.from("notes").select("*").eq("title", title);
|
|
386
|
+
assert.equal(notes.length, 1, "the inserted note is readable by its owner");
|
|
387
|
+
await harbour.data.from("notes").delete().eq("id", notes[0].id);
|
|
388
|
+
const { data: remaining } = await harbour.data.from("notes").select("id").eq("title", title);
|
|
389
|
+
assert.equal(remaining.length, 0, "the note is deleted");
|
|
390
|
+
`;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.1.
|
|
1
|
+
export const CLI_VERSION = "0.1.14";
|
package/dist/src/analyzer.js
CHANGED
|
@@ -12,6 +12,8 @@ const MAX_FILE_BYTES = 256_000;
|
|
|
12
12
|
// application files. Keeping them out also makes reruns idempotent after a
|
|
13
13
|
// previous save has written a receipt into the selected workspace.
|
|
14
14
|
const IGNORED_DIRS = new Set([".git", ".harbour", ".next", ".nuxt", ".svelte-kit", ".turbo", "coverage", "dist", "build", "node_modules", "vendor"]);
|
|
15
|
+
/** The development kit commits exactly these two files under `.harbour`; everything else there (local state, checks evidence, generated control files) stays out of the package. */
|
|
16
|
+
const KIT_COMMITTED_FILES = new Set([".harbour/integrations.json", ".harbour/kit.lock.json"]);
|
|
15
17
|
const SECRET_FILE_NAMES = new Set([".env", ".env.local", ".env.production", ".env.development", ".npmrc"]);
|
|
16
18
|
const TEXT_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".json", ".html", ".css", ".py", ".rb", ".go", ".rs", ".java", ".cs", ".php", ".md", ".toml", ".yaml", ".yml", ".sh"]);
|
|
17
19
|
function normalizeIncludePath(value) {
|
|
@@ -27,7 +29,7 @@ function normalizeIncludePath(value) {
|
|
|
27
29
|
async function collectIncludedFiles(root, includePaths, output, unknowns) {
|
|
28
30
|
const normalized = [...new Set(includePaths.map(normalizeIncludePath))];
|
|
29
31
|
for (const include of normalized) {
|
|
30
|
-
if (include.split("/").some(part => IGNORED_DIRS.has(part)))
|
|
32
|
+
if (include.split("/").some(part => IGNORED_DIRS.has(part)) && !KIT_COMMITTED_FILES.has(include))
|
|
31
33
|
continue;
|
|
32
34
|
const absolute = include ? join(root, ...include.split("/")) : root;
|
|
33
35
|
let info;
|
|
@@ -75,6 +77,8 @@ async function collectFiles(root, current, output, unknowns) {
|
|
|
75
77
|
if (entry.isDirectory()) {
|
|
76
78
|
if (!IGNORED_DIRS.has(entry.name))
|
|
77
79
|
await collectFiles(root, join(current, entry.name), output, unknowns);
|
|
80
|
+
else if (entry.name === ".harbour" && current === root)
|
|
81
|
+
await collectKitFiles(root, output);
|
|
78
82
|
continue;
|
|
79
83
|
}
|
|
80
84
|
if (!entry.isFile())
|
|
@@ -82,6 +86,20 @@ async function collectFiles(root, current, output, unknowns) {
|
|
|
82
86
|
await collectFile(root, join(current, entry.name), output);
|
|
83
87
|
}
|
|
84
88
|
}
|
|
89
|
+
async function collectKitFiles(root, output) {
|
|
90
|
+
for (const path of [...KIT_COMMITTED_FILES].sort()) {
|
|
91
|
+
const absolute = join(root, ...path.split("/"));
|
|
92
|
+
let info;
|
|
93
|
+
try {
|
|
94
|
+
info = await lstat(absolute);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (info.isFile() && !info.isSymbolicLink())
|
|
100
|
+
await collectFile(root, absolute, output);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
85
103
|
async function collectFile(root, absolute, output) {
|
|
86
104
|
const path = relative(root, absolute);
|
|
87
105
|
if (output.some(file => file.path === path))
|
package/dist/src/secret-paths.js
CHANGED
|
@@ -17,3 +17,11 @@ export function isSourceIntakeExcludedPath(path) {
|
|
|
17
17
|
return /(^|\/)(?:__MACOSX)(?:\/|$)|(^|\/)\.DS_Store$/i.test(path)
|
|
18
18
|
|| /(^|\/)\.github\/workflows\//i.test(path);
|
|
19
19
|
}
|
|
20
|
+
/** The development kit commits exactly these two files under `.harbour`; everything else there stays local. */
|
|
21
|
+
export function isKitCommittedPath(path) {
|
|
22
|
+
return /(^|\/)\.harbour\/(?:integrations\.json|kit\.lock\.json)$/.test(path);
|
|
23
|
+
}
|
|
24
|
+
/** Dependency and internal directories the intake never stages — `.harbour` except its two committed kit files. */
|
|
25
|
+
export function isInternalSourcePath(path) {
|
|
26
|
+
return /(^|\/)(?:node_modules|\.git)(?:\/|$)/i.test(path) || (/(^|\/)\.harbour(?:\/|$)/i.test(path) && !isKitCommittedPath(path));
|
|
27
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { sha256, sha256Bytes, stableJson } from "./digest.js";
|
|
2
|
-
import { isProhibitedSecretPath } from "./secret-paths.js";
|
|
2
|
+
import { isInternalSourcePath, isProhibitedSecretPath } from "./secret-paths.js";
|
|
3
3
|
export class MemorySourceArchiveStore {
|
|
4
4
|
archives = new Map();
|
|
5
5
|
async put(objectKey, archive) { this.archives.set(objectKey, clone(archive)); }
|
|
@@ -113,7 +113,7 @@ function safeSourcePath(path) {
|
|
|
113
113
|
// Checked-in env templates are configuration metadata, not runtime
|
|
114
114
|
// credentials. All other env variants remain excluded before staging.
|
|
115
115
|
&& !isProhibitedSecretPath(path)
|
|
116
|
-
&&
|
|
116
|
+
&& !isInternalSourcePath(path);
|
|
117
117
|
}
|
|
118
118
|
function toBytes(value) { return typeof value === "string" ? new TextEncoder().encode(value) : value; }
|
|
119
119
|
function bytesToBase64(bytes) { return Buffer.from(bytes).toString("base64"); }
|