ignotum 0.0.7 → 0.0.9

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.
Files changed (58) hide show
  1. package/README.md +89 -101
  2. package/dist/cli/bin.mjs +1119 -195
  3. package/dist/cli/bin.mjs.map +1 -1
  4. package/dist/runtime/{api-DtX8qPrq.js → api-DzcR7spt.js} +36 -13
  5. package/dist/runtime/api-DzcR7spt.js.map +1 -0
  6. package/dist/runtime/{api-Dp4J-xt-.d.ts → api-jCl8Hzry.d.ts} +7 -9
  7. package/dist/runtime/client.d.ts +12 -5
  8. package/dist/runtime/client.js +285 -60
  9. package/dist/runtime/client.js.map +1 -1
  10. package/dist/runtime/{descriptor-t6BOEGw9-C1rwYIlx.js → descriptor-C5VA9qRl-BKbdenuU.js} +38 -7
  11. package/dist/runtime/descriptor-C5VA9qRl-BKbdenuU.js.map +1 -0
  12. package/dist/runtime/file-C1abuMgd.js +173 -0
  13. package/dist/runtime/file-C1abuMgd.js.map +1 -0
  14. package/dist/runtime/{id-Btwac71X-DhnKYsjY.d.ts → id-Cs82tq9Q-Caqfx54f.d.ts} +51 -5
  15. package/dist/runtime/{index-B5KSOjGN.d.ts → index-D6VLTbDB.d.ts} +40 -21
  16. package/dist/runtime/internal/api.d.ts +2 -2
  17. package/dist/runtime/internal/api.js +1 -1
  18. package/dist/runtime/internal/host.d.ts +8 -7
  19. package/dist/runtime/internal/host.js +21 -10
  20. package/dist/runtime/internal/host.js.map +1 -1
  21. package/dist/runtime/internal/server.d.ts +1 -1
  22. package/dist/runtime/internal/server.js +1 -1
  23. package/dist/runtime/internal/types.d.ts +1 -1
  24. package/dist/runtime/internal/types.js +1 -1
  25. package/dist/runtime/{pagination-B1BzNkh8-BUSTbeSg.d.ts → pagination-Bt3l7QaC-D_zI5zsu.d.ts} +8 -4
  26. package/dist/runtime/pagination-DcIkTOFs.d.ts +1 -0
  27. package/dist/runtime/{schema-D9RmboaS.js → schema-B6PK_ZwV.js} +17 -3
  28. package/dist/runtime/schema-B6PK_ZwV.js.map +1 -0
  29. package/dist/runtime/server.d.ts +3 -3
  30. package/dist/runtime/server.js +2 -2
  31. package/dist/runtime/server.js.map +1 -1
  32. package/dist/runtime/sync-bMQq9tXx.d.ts +8 -0
  33. package/package.json +7 -5
  34. package/src/cli/agent-files.ts +52 -13
  35. package/src/cli/app-configuration.ts +4 -1
  36. package/src/cli/auth-client.ts +286 -0
  37. package/src/cli/build/server.ts +83 -6
  38. package/src/cli/command.ts +65 -2
  39. package/src/cli/control-client.ts +19 -6
  40. package/src/cli/new-app.ts +16 -0
  41. package/src/client/errors.ts +7 -10
  42. package/src/client/files.ts +153 -0
  43. package/src/client/hooks.ts +2 -15
  44. package/src/client/index.ts +7 -0
  45. package/src/client/sync.ts +158 -30
  46. package/src/dev-runtime/database.ts +69 -57
  47. package/src/dev-runtime/files.ts +337 -0
  48. package/src/dev-runtime/functions.ts +14 -6
  49. package/src/dev-runtime/migrations.ts +14 -0
  50. package/src/dev-runtime/sync.ts +134 -22
  51. package/src/internal/api.ts +16 -1
  52. package/src/server/index.ts +8 -1
  53. package/dist/runtime/api-DtX8qPrq.js.map +0 -1
  54. package/dist/runtime/descriptor-t6BOEGw9-C1rwYIlx.js.map +0 -1
  55. package/dist/runtime/id-D570vudg.js +0 -26
  56. package/dist/runtime/id-D570vudg.js.map +0 -1
  57. package/dist/runtime/pagination-BKPko9Hm.d.ts +0 -1
  58. package/dist/runtime/schema-D9RmboaS.js.map +0 -1
@@ -0,0 +1,286 @@
1
+ import { homedir } from "node:os";
2
+ import process from "node:process";
3
+
4
+ import * as Versioned from "@ignotum/contracts/versioned";
5
+ import { createAuthClient } from "better-auth/client";
6
+ import { deviceAuthorizationClient, organizationClient } from "better-auth/client/plugins";
7
+ import {
8
+ Config,
9
+ Context,
10
+ Effect,
11
+ FileSystem,
12
+ Layer,
13
+ Option,
14
+ Path,
15
+ Redacted,
16
+ Schema,
17
+ Terminal,
18
+ } from "effect";
19
+
20
+ const CredentialV1 = Schema.Struct({
21
+ formatVersion: Schema.Literal(1),
22
+ authUrl: Schema.URLFromString,
23
+ token: Schema.String,
24
+ });
25
+ const CredentialThroughV1 = Versioned.initial(CredentialV1);
26
+ const Credential = CredentialThroughV1;
27
+ type Credential = typeof Credential.Type;
28
+
29
+ const AuthUrl = Schema.URL.check(
30
+ Schema.makeFilter(
31
+ (url) =>
32
+ url.protocol === "https:" ||
33
+ (url.protocol === "http:" && ["127.0.0.1", "::1", "localhost"].includes(url.hostname)),
34
+ { message: "The auth URL must use HTTPS unless it targets localhost." },
35
+ ),
36
+ );
37
+
38
+ export class AuthCommandError extends Schema.TaggedError<AuthCommandError>()("AuthCommandError", {
39
+ cause: Schema.optional(Schema.Defect()),
40
+ message: Schema.String,
41
+ }) {}
42
+
43
+ const commandError = (message: string, cause?: unknown) =>
44
+ AuthCommandError.make(cause === undefined ? { message } : { cause, message });
45
+
46
+ interface StoredCredential {
47
+ readonly authUrl: URL;
48
+ readonly token: Redacted.Redacted<string>;
49
+ }
50
+
51
+ interface AuthCredentialStoreService {
52
+ readonly load: Effect.Effect<StoredCredential | undefined, AuthCommandError>;
53
+ readonly remove: Effect.Effect<boolean, AuthCommandError>;
54
+ readonly save: (credential: StoredCredential) => Effect.Effect<void, AuthCommandError>;
55
+ }
56
+
57
+ export class AuthCredentialStore extends Context.Service<
58
+ AuthCredentialStore,
59
+ AuthCredentialStoreService
60
+ >()("ignotum/cli/auth-client/AuthCredentialStore") {
61
+ static readonly layer = Layer.effect(
62
+ AuthCredentialStore,
63
+ Effect.gen(function* () {
64
+ const fileSystem = yield* FileSystem.FileSystem;
65
+ const path = yield* Path.Path;
66
+ const configured = yield* Config.option(Config.string("IGNOTUM_CONFIG_DIR"));
67
+ const xdg = yield* Config.option(Config.string("XDG_CONFIG_HOME"));
68
+ const appData = yield* Config.option(Config.string("APPDATA"));
69
+ const configurationDirectory = Option.getOrElse(configured, () =>
70
+ Option.getOrElse(xdg, () =>
71
+ process.platform === "win32" && Option.isSome(appData)
72
+ ? appData.value
73
+ : path.join(homedir(), ".config"),
74
+ ),
75
+ );
76
+ const directory = path.join(configurationDirectory, "ignotum");
77
+ const credentialPath = path.join(directory, "auth.json");
78
+
79
+ const load = Effect.fn("AuthCredentialStore.load")(function* () {
80
+ if (
81
+ !(yield* fileSystem
82
+ .exists(credentialPath)
83
+ .pipe(
84
+ Effect.mapError((cause) => commandError("Could not check the saved login.", cause)),
85
+ ))
86
+ ) {
87
+ return undefined;
88
+ }
89
+ const text = yield* fileSystem
90
+ .readFileString(credentialPath)
91
+ .pipe(Effect.mapError((cause) => commandError("Could not read the saved login.", cause)));
92
+ const credential = yield* Schema.decodeEffect(Schema.fromJsonString(Credential))(text).pipe(
93
+ Effect.mapError((cause) => commandError("The saved login is invalid.", cause)),
94
+ );
95
+ return {
96
+ authUrl: credential.authUrl,
97
+ token: Redacted.make(credential.token),
98
+ } satisfies StoredCredential;
99
+ });
100
+
101
+ const save = Effect.fn("AuthCredentialStore.save")(function* (credential: StoredCredential) {
102
+ const value = {
103
+ formatVersion: 1,
104
+ authUrl: credential.authUrl,
105
+ token: Redacted.value(credential.token),
106
+ } satisfies Credential;
107
+ const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(Credential))(value).pipe(
108
+ Effect.mapError((cause) => commandError("Could not encode the login.", cause)),
109
+ );
110
+ yield* fileSystem
111
+ .makeDirectory(directory, { mode: 0o700, recursive: true })
112
+ .pipe(
113
+ Effect.mapError((cause) =>
114
+ commandError("Could not create the config directory.", cause),
115
+ ),
116
+ );
117
+ yield* Effect.scoped(
118
+ Effect.gen(function* () {
119
+ const temporaryPath = yield* fileSystem.makeTempFileScoped({
120
+ directory,
121
+ prefix: ".auth-",
122
+ suffix: ".json",
123
+ });
124
+ yield* fileSystem.writeFileString(temporaryPath, `${encoded}\n`, { mode: 0o600 });
125
+ yield* fileSystem.rename(temporaryPath, credentialPath);
126
+ }),
127
+ ).pipe(Effect.mapError((cause) => commandError("Could not save the login.", cause)));
128
+ });
129
+
130
+ const remove = Effect.fn("AuthCredentialStore.remove")(function* () {
131
+ if (
132
+ !(yield* fileSystem
133
+ .exists(credentialPath)
134
+ .pipe(
135
+ Effect.mapError((cause) => commandError("Could not check the saved login.", cause)),
136
+ ))
137
+ ) {
138
+ return false;
139
+ }
140
+ yield* fileSystem
141
+ .remove(credentialPath)
142
+ .pipe(
143
+ Effect.mapError((cause) => commandError("Could not remove the saved login.", cause)),
144
+ );
145
+ return true;
146
+ });
147
+
148
+ return AuthCredentialStore.of({ load: load(), remove: remove(), save });
149
+ }),
150
+ );
151
+ }
152
+
153
+ const makeClient = (authUrl: URL) =>
154
+ createAuthClient({
155
+ basePath: "/v1",
156
+ baseURL: authUrl.href,
157
+ plugins: [deviceAuthorizationClient(), organizationClient()],
158
+ });
159
+
160
+ const authorization = (token: Redacted.Redacted<string>) => ({
161
+ Authorization: `Bearer ${Redacted.value(token)}`,
162
+ });
163
+
164
+ const responseError = (error: { readonly error_description?: string; readonly message?: string }) =>
165
+ error.error_description ?? error.message ?? "Authentication failed.";
166
+
167
+ export const login = Effect.fn("Auth.login")(function* () {
168
+ const store = yield* AuthCredentialStore;
169
+ const terminal = yield* Terminal.Terminal;
170
+ const authUrl = yield* Config.schema(AuthUrl, "IGNOTUM_AUTH_URL").pipe(
171
+ Config.withDefault(new URL("https://auth.ignotum.cloud")),
172
+ Effect.mapError((cause) => commandError("The auth URL is invalid.", cause)),
173
+ );
174
+ const client = makeClient(authUrl);
175
+ const existing = yield* store.load;
176
+ if (existing !== undefined && existing.authUrl.href === authUrl.href) {
177
+ const result = yield* Effect.tryPromise({
178
+ try: () => client.getSession({ fetchOptions: { headers: authorization(existing.token) } }),
179
+ catch: (cause) => commandError("Could not check the saved login.", cause),
180
+ });
181
+ if (result.data?.user !== undefined) {
182
+ return {
183
+ email: result.data.user.email,
184
+ verificationUrl: undefined,
185
+ };
186
+ }
187
+ }
188
+
189
+ const started = yield* Effect.tryPromise({
190
+ try: () => client.device.code({ client_id: "ignotum-cli", scope: "openid profile email" }),
191
+ catch: (cause) => commandError("Could not start login.", cause),
192
+ });
193
+ if (started.data === null) {
194
+ return yield* commandError(responseError(started.error));
195
+ }
196
+ const url = started.data.verification_uri_complete ?? started.data.verification_uri;
197
+ yield* terminal.display(
198
+ `Open ${started.data.verification_uri}\nCode: ${started.data.user_code}\n\nWaiting for approval...\n`,
199
+ );
200
+ yield* Effect.tryPromise({
201
+ // @effect-diagnostics-next-line asyncFunction:off Dynamic import keeps browser-only code out of other CLI commands.
202
+ try: async () => (await import("open")).default(url),
203
+ catch: (cause) => commandError("Could not open the browser.", cause),
204
+ }).pipe(Effect.ignore);
205
+
206
+ let interval = started.data.interval ?? 5;
207
+ while (true) {
208
+ yield* Effect.sleep(`${interval} seconds`);
209
+ const polled = yield* Effect.tryPromise({
210
+ try: () =>
211
+ client.device.token({
212
+ client_id: "ignotum-cli",
213
+ device_code: started.data.device_code,
214
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
215
+ }),
216
+ catch: (cause) => commandError("Login polling failed.", cause),
217
+ });
218
+ if (polled.data?.access_token !== undefined) {
219
+ const token = Redacted.make(polled.data.access_token);
220
+ const session = yield* Effect.tryPromise({
221
+ try: () => client.getSession({ fetchOptions: { headers: authorization(token) } }),
222
+ catch: (cause) => commandError("Could not read the new session.", cause),
223
+ });
224
+ if (session.data?.user === undefined) {
225
+ return yield* commandError("The auth service returned an invalid session.");
226
+ }
227
+ yield* store.save({ authUrl, token });
228
+ return { email: session.data.user.email, verificationUrl: url };
229
+ }
230
+ switch (polled.error?.error) {
231
+ case "authorization_pending":
232
+ break;
233
+ case "slow_down":
234
+ interval += 5;
235
+ break;
236
+ case "access_denied":
237
+ return yield* commandError("Login was denied.");
238
+ case "expired_token":
239
+ return yield* commandError("The login code expired. Run login again.");
240
+ default:
241
+ return yield* commandError(
242
+ polled.error === null ? "Login failed." : responseError(polled.error),
243
+ );
244
+ }
245
+ }
246
+ });
247
+
248
+ export const logout = Effect.fn("Auth.logout")(function* () {
249
+ const store = yield* AuthCredentialStore;
250
+ const credential = yield* store.load;
251
+ if (credential === undefined) return false;
252
+ const client = makeClient(credential.authUrl);
253
+ const result = yield* Effect.tryPromise({
254
+ try: () => client.signOut({ fetchOptions: { headers: authorization(credential.token) } }),
255
+ catch: (cause) => commandError("Could not revoke the session.", cause),
256
+ });
257
+ if (result.error !== null) {
258
+ return yield* commandError(responseError(result.error));
259
+ }
260
+ yield* store.remove;
261
+ return true;
262
+ });
263
+
264
+ export const status = Effect.fn("Auth.status")(function* () {
265
+ const store = yield* AuthCredentialStore;
266
+ const credential = yield* store.load;
267
+ if (credential === undefined) return undefined;
268
+ const client = makeClient(credential.authUrl);
269
+ const session = yield* Effect.tryPromise({
270
+ try: () => client.getSession({ fetchOptions: { headers: authorization(credential.token) } }),
271
+ catch: (cause) => commandError("Could not check login status.", cause),
272
+ });
273
+ if (session.data?.user === undefined) return undefined;
274
+ const teams = yield* Effect.tryPromise({
275
+ try: () =>
276
+ client.organization.list({
277
+ fetchOptions: { headers: authorization(credential.token) },
278
+ }),
279
+ catch: (cause) => commandError("Could not load the team.", cause),
280
+ });
281
+ return {
282
+ email: session.data.user.email,
283
+ name: session.data.user.name,
284
+ teamName: teams.data?.[0]?.name,
285
+ };
286
+ });
@@ -16,7 +16,15 @@ import {
16
16
  } from "@ignotum/contracts/deployment";
17
17
  import { FunctionAddress } from "@ignotum/contracts/runtime/sync";
18
18
  import type { FunctionKind } from "@ignotum/contracts/runtime/functions";
19
- import { isDefinedSchema, type DefinedSchema, type Tables } from "@ignotum/contracts/schema";
19
+ import {
20
+ descriptorContainsFile,
21
+ descriptorFields,
22
+ getValueDescriptor,
23
+ isDefinedSchema,
24
+ ValueDescriptor,
25
+ type DefinedSchema,
26
+ type Tables,
27
+ } from "@ignotum/contracts/schema";
20
28
  import {
21
29
  artifactReference,
22
30
  encodeCanonical,
@@ -41,17 +49,23 @@ const encodeJavaScriptString = Schema.encodeSync(Schema.fromJsonString(Schema.St
41
49
 
42
50
  const DiscoveredServerFunction = Schema.Struct({
43
51
  address: FunctionAddress,
52
+ args: Schema.optional(ValueDescriptor),
53
+ errors: Schema.optional(ValueDescriptor),
44
54
  exportName: Schema.String,
45
55
  kind: Schema.Literals(["Mutation", "Query"]),
46
56
  moduleName: Schema.String,
47
57
  modulePath: Schema.String,
58
+ returns: Schema.optional(ValueDescriptor),
48
59
  });
49
60
  interface DiscoveredServerFunction {
50
61
  readonly address: FunctionAddress;
62
+ readonly args?: ValueDescriptor;
63
+ readonly errors?: ValueDescriptor;
51
64
  readonly exportName: string;
52
65
  readonly kind: FunctionKind;
53
66
  readonly moduleName: string;
54
67
  readonly modulePath: string;
68
+ readonly returns?: ValueDescriptor;
55
69
  }
56
70
 
57
71
  const ServerDiscoveryManifest = Schema.Struct({
@@ -179,13 +193,68 @@ const discoverModuleFunctions = Effect.fn("Deploy.discoverServerModule")(functio
179
193
  continue;
180
194
  }
181
195
 
182
- functions.push({
196
+ const args =
197
+ inspected.definition.args === undefined
198
+ ? undefined
199
+ : ({ type: "object", fields: descriptorFields(inspected.definition.args) } as const);
200
+ const returns =
201
+ inspected.definition.returns === undefined
202
+ ? undefined
203
+ : getValueDescriptor(inspected.definition.returns);
204
+ const errors =
205
+ inspected.definition.errors === undefined
206
+ ? undefined
207
+ : getValueDescriptor(inspected.definition.errors);
208
+ if (
209
+ (inspected.definition.returns !== undefined && returns === undefined) ||
210
+ (inspected.definition.errors !== undefined && errors === undefined)
211
+ ) {
212
+ return yield* InvalidServerFunctionExport.make({
213
+ exportName,
214
+ message: `${moduleName}.${exportName} uses a validator that was not created by Ignotum values.`,
215
+ path: modulePath,
216
+ });
217
+ }
218
+ if (
219
+ inspected.definition._tag === "Query" &&
220
+ args !== undefined &&
221
+ descriptorContainsFile(args)
222
+ ) {
223
+ return yield* InvalidServerFunctionExport.make({
224
+ exportName,
225
+ message: `${moduleName}.${exportName} cannot accept files in query arguments.`,
226
+ path: modulePath,
227
+ });
228
+ }
229
+ if (
230
+ inspected.definition._tag === "Mutation" &&
231
+ returns !== undefined &&
232
+ descriptorContainsFile(returns)
233
+ ) {
234
+ return yield* InvalidServerFunctionExport.make({
235
+ exportName,
236
+ message: `${moduleName}.${exportName} cannot return files from a mutation.`,
237
+ path: modulePath,
238
+ });
239
+ }
240
+ if (errors !== undefined && descriptorContainsFile(errors)) {
241
+ return yield* InvalidServerFunctionExport.make({
242
+ exportName,
243
+ message: `${moduleName}.${exportName} cannot include files in application errors.`,
244
+ path: modulePath,
245
+ });
246
+ }
247
+
248
+ const discovered = {
183
249
  address: FunctionAddress.make(`api.${moduleName}.${exportName}`),
184
250
  exportName,
185
251
  kind: inspected.definition._tag,
186
252
  moduleName,
187
253
  modulePath,
188
- });
254
+ };
255
+ const withArgs = args === undefined ? discovered : { ...discovered, args };
256
+ const withErrors = errors === undefined ? withArgs : { ...withArgs, errors };
257
+ functions.push(returns === undefined ? withErrors : { ...withErrors, returns });
189
258
  }
190
259
 
191
260
  return Array.sortWith(functions, (definition) => definition.address, String.Order);
@@ -319,6 +388,7 @@ const discoverServerFunctions = Effect.fn("Deploy.discoverServerFunctionsIsolate
319
388
  appDirectory,
320
389
  nodeModulesDirectory(),
321
390
  packageRoot,
391
+ path.join(packageRoot, "node_modules"),
322
392
  ...workspaceProbes,
323
393
  ].map((allowedPath) => `--allow-fs-read=${allowedPath}`);
324
394
  const exitCode = yield* spawner.exitCode(
@@ -476,7 +546,7 @@ const buildServerFunction = Effect.fn("Deploy.buildServerFunction")(function* (
476
546
  const bundlePath = normalizePath(path.join("server", relativeFile));
477
547
  const sourceMapPath = normalizePath(path.join("server", relativeSourceMap));
478
548
 
479
- return {
549
+ const artifact = {
480
550
  address: definition.address,
481
551
  kind: definition.kind,
482
552
  bundle: yield* artifactReference(bundlePath, yield* fileSystem.readFile(absoluteFile)),
@@ -484,7 +554,14 @@ const buildServerFunction = Effect.fn("Deploy.buildServerFunction")(function* (
484
554
  sourceMapPath,
485
555
  yield* fileSystem.readFile(absoluteSourceMap),
486
556
  ),
487
- } satisfies ServerFunctionArtifact;
557
+ };
558
+ const withArgs =
559
+ definition.args === undefined ? artifact : { ...artifact, args: definition.args };
560
+ const withErrors =
561
+ definition.errors === undefined ? withArgs : { ...withArgs, errors: definition.errors };
562
+ return definition.returns === undefined
563
+ ? (withErrors satisfies ServerFunctionArtifact)
564
+ : ({ ...withErrors, returns: definition.returns } satisfies ServerFunctionArtifact);
488
565
  });
489
566
 
490
567
  export const buildServer = Effect.fn("Deploy.buildServer")(function* (
@@ -507,7 +584,7 @@ export const buildServer = Effect.fn("Deploy.buildServer")(function* (
507
584
  { concurrency: 4 },
508
585
  );
509
586
  const manifest = {
510
- formatVersion: 1,
587
+ formatVersion: 2,
511
588
  schema: yield* artifactReference(schemaSnapshotPath, snapshotBytes),
512
589
  functions,
513
590
  } satisfies ServerBuildManifest;
@@ -6,6 +6,12 @@ import { IdGenerator } from "@ignotum/shared/id";
6
6
 
7
7
  import packageJson from "../../package.json" with { type: "json" };
8
8
  import { generate } from "./codegen.js";
9
+ import {
10
+ AuthCredentialStore,
11
+ login as runLogin,
12
+ logout as runLogout,
13
+ status as readAuthStatus,
14
+ } from "./auth-client.js";
9
15
  import { ControlClient } from "./control-client.js";
10
16
  import { deploy as runDeploy } from "./deploy.js";
11
17
  import { resetDevDatabase } from "../dev-runtime/dev-database.js";
@@ -15,6 +21,58 @@ import { installDependencies } from "./package-manager.js";
15
21
 
16
22
  const DevPort = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65_535 }));
17
23
 
24
+ const authLogin = Command.make(
25
+ "login",
26
+ {},
27
+ Effect.fn("auth login")(function* () {
28
+ const terminal = yield* Terminal.Terminal;
29
+ const result = yield* runLogin().pipe(
30
+ Effect.mapError((error) =>
31
+ CliError.UserError.make({ cause: error, userMessage: error.message }),
32
+ ),
33
+ );
34
+ yield* terminal.display(`Signed in as ${result.email}.\n`);
35
+ }),
36
+ ).pipe(Command.withDescription("Sign in to Ignotum with GitHub."));
37
+
38
+ const authLogout = Command.make(
39
+ "logout",
40
+ {},
41
+ Effect.fn("auth logout")(function* () {
42
+ const terminal = yield* Terminal.Terminal;
43
+ const removed = yield* runLogout().pipe(
44
+ Effect.mapError((error) =>
45
+ CliError.UserError.make({ cause: error, userMessage: error.message }),
46
+ ),
47
+ );
48
+ yield* terminal.display(removed ? "Signed out.\n" : "Not signed in.\n");
49
+ }),
50
+ ).pipe(Command.withDescription("Revoke and remove the saved login."));
51
+
52
+ const authStatus = Command.make(
53
+ "status",
54
+ {},
55
+ Effect.fn("auth status")(function* () {
56
+ const terminal = yield* Terminal.Terminal;
57
+ const current = yield* readAuthStatus().pipe(
58
+ Effect.mapError((error) =>
59
+ CliError.UserError.make({ cause: error, userMessage: error.message }),
60
+ ),
61
+ );
62
+ if (current === undefined) {
63
+ yield* terminal.display("Not signed in.\n");
64
+ return;
65
+ }
66
+ const team = current.teamName === undefined ? "" : `\nTeam: ${current.teamName}`;
67
+ yield* terminal.display(`Signed in as ${current.name} (${current.email}).${team}\n`);
68
+ }),
69
+ ).pipe(Command.withDescription("Show the current Ignotum login."));
70
+
71
+ const auth = Command.make("auth").pipe(
72
+ Command.withDescription("Manage your Ignotum login."),
73
+ Command.withSubcommands([authLogin, authLogout, authStatus]),
74
+ );
75
+
18
76
  const codegen = Command.make(
19
77
  "codegen",
20
78
  {},
@@ -172,7 +230,7 @@ const newApp = Command.make(
172
230
  );
173
231
 
174
232
  const ignotum = Command.make("ignotum").pipe(
175
- Command.withSubcommands([newApp, install, codegen, dev, deploy]),
233
+ Command.withSubcommands([newApp, install, codegen, dev, deploy, auth]),
176
234
  );
177
235
 
178
236
  export const main = (): void => {
@@ -180,7 +238,12 @@ export const main = (): void => {
180
238
  Command.run({ version: packageJson.version }),
181
239
  // @effect-diagnostics-next-line strictEffectProvide:off
182
240
  Effect.provide(
183
- Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, IdGenerator.layer),
241
+ Layer.mergeAll(
242
+ NodeServices.layer,
243
+ NodeHttpClient.layerUndici,
244
+ IdGenerator.layer,
245
+ AuthCredentialStore.layer.pipe(Layer.provide(NodeServices.layer)),
246
+ ),
184
247
  ),
185
248
  NodeRuntime.runMain,
186
249
  );
@@ -41,6 +41,7 @@ import {
41
41
  } from "effect";
42
42
  import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
43
43
  import type { HttpClientRequest as HttpRequest } from "effect/unstable/http/HttpClientRequest";
44
+ import { AuthCommandError, AuthCredentialStore } from "./auth-client.js";
44
45
 
45
46
  export interface HostedControlConfig {
46
47
  readonly apiUrl: URL;
@@ -69,11 +70,19 @@ export class HostedControlConfiguration extends Context.Service<
69
70
  >()("ignotum/cli/control-client/HostedControlConfiguration") {
70
71
  static readonly layer = Layer.effect(
71
72
  HostedControlConfiguration,
72
- Config.all({
73
- apiUrl: Config.schema(HostedApiUrl, "IGNOTUM_API_URL").pipe(
74
- Config.withDefault(new URL("https://api.ignotum.cloud")),
75
- ),
76
- token: Config.redacted("IGNOTUM_API_TOKEN"),
73
+ Effect.gen(function* () {
74
+ const credential = yield* (yield* AuthCredentialStore).load;
75
+ if (credential === undefined) {
76
+ return yield* AuthCommandError.make({
77
+ message: "Run 'npx ignotum auth login' first.",
78
+ });
79
+ }
80
+ return {
81
+ apiUrl: yield* Config.schema(HostedApiUrl, "IGNOTUM_API_URL").pipe(
82
+ Config.withDefault(new URL("https://api.ignotum.cloud")),
83
+ ),
84
+ token: credential.token,
85
+ };
77
86
  }),
78
87
  );
79
88
  }
@@ -133,7 +142,11 @@ export class ControlClient extends Context.Service<ControlClient, ControlClientS
133
142
  "ignotum/cli/control-client/ControlClient",
134
143
  ) {
135
144
  static get layer() {
136
- return controlClientLayer.pipe(Layer.provideMerge(HostedControlConfiguration.layer));
145
+ return controlClientLayer.pipe(
146
+ Layer.provideMerge(
147
+ HostedControlConfiguration.layer.pipe(Layer.provide(AuthCredentialStore.layer)),
148
+ ),
149
+ );
137
150
  }
138
151
  }
139
152
 
@@ -207,6 +207,18 @@ Open <http://127.0.0.1:3210>.
207
207
  The app generator creates \`_generated\`. The dev server checks those files before it starts
208
208
  and updates them when the schema or server functions change.
209
209
 
210
+ ## Deploy
211
+
212
+ Sign in, then choose a unique slug on the first deployment:
213
+
214
+ \`\`\`sh
215
+ npx ignotum auth login
216
+ npx ignotum deploy --app my-app
217
+ \`\`\`
218
+
219
+ The command prints the hosted URL and records the app link in \`.ignotum/app.json\`. Later
220
+ deployments use \`npx ignotum deploy\` without the \`--app\` flag.
221
+
210
222
  ## App files
211
223
 
212
224
  - \`server/schema.ts\` defines the database tables.
@@ -216,6 +228,7 @@ and updates them when the schema or server functions change.
216
228
  - Ignotum loads Tailwind CSS automatically. Custom CSS files are ordinary client modules.
217
229
  - \`shared/utils.ts\` contains code shared across the app.
218
230
  - \`_generated\` contains Ignotum's generated types and bindings. Do not edit it by hand.
231
+ - \`.agents/skills/ignotum\` contains the app skill and references used by coding agents.
219
232
 
220
233
  Run the typechecker after a change:
221
234
 
@@ -224,6 +237,9 @@ npx ignotum codegen
224
237
  npx tsc --noEmit
225
238
  \`\`\`
226
239
 
240
+ The [Ignotum documentation](https://docs.ignotum.cloud) covers schema values, indexes, queries,
241
+ mutations, client hooks, deployment, guarantees, and hosted limits.
242
+
227
243
  ## Claude Code
228
244
 
229
245
  If you use Claude Code, rename \`AGENTS.md\` to \`CLAUDE.md\` and \`.agents\` to \`.claude\` so it
@@ -1,7 +1,7 @@
1
1
  /** @effect-diagnostics globalConsole:skip-file */
2
2
  import { Cause, Effect, ErrorReporter, Schema } from "effect";
3
3
 
4
- import { FunctionAddress, Operation, ProtocolErrorCode } from "@ignotum/contracts/runtime/sync";
4
+ import { ErrorCode, FunctionAddress, Operation } from "@ignotum/contracts/runtime/sync";
5
5
 
6
6
  export class ConnectionUnavailable extends Schema.TaggedError<ConnectionUnavailable>()(
7
7
  "ConnectionUnavailable",
@@ -36,21 +36,18 @@ export class InvalidServerMessage extends Schema.TaggedError<InvalidServerMessag
36
36
  },
37
37
  ) {}
38
38
 
39
- export class ServerProtocolError extends Schema.TaggedError<ServerProtocolError>()(
40
- "ServerProtocolError",
41
- {
42
- code: ProtocolErrorCode,
43
- message: Schema.String,
44
- operation: Schema.optional(Operation),
45
- },
46
- ) {}
39
+ export class ServerError extends Schema.TaggedError<ServerError>()("ServerError", {
40
+ code: ErrorCode,
41
+ message: Schema.String,
42
+ operation: Schema.optional(Operation),
43
+ }) {}
47
44
 
48
45
  export const ClientInfrastructureError = Schema.Union([
49
46
  ConnectionUnavailable,
50
47
  InvalidClientMessage,
51
48
  InvalidMutationArguments,
52
49
  InvalidServerMessage,
53
- ServerProtocolError,
50
+ ServerError,
54
51
  ]);
55
52
  export type ClientInfrastructureError = typeof ClientInfrastructureError.Type;
56
53