From f4dc01cd07646d1cc8872152581d0cd99f5ed4ae Mon Sep 17 00:00:00 2001 From: Drew Harris Date: Tue, 6 Jan 2026 13:12:07 -0800 Subject: [PATCH 01/15] effect rewrite of cli - finish global opts - info command - move around init - more stuff - pull all + push perms done - a lot done + claim - start of push - wow? - clean up old version + info - parity - opts from command - remove todo - add back old pull - formatting - finish init - schema read file error - swap to coerce login - fix ts error - normaler error messages - fix type - fix logout fix coerce same error format feedback enable silent logging more logging fixes low hanging fruit explorer command track app imports add bun as potential env add solid package format for init without files allow admin token as auth fix ci tests - use new cli experimental type preservation query command instant.config.ts support fix a lot --- client/packages/cli/__tests__/e2e/helpers.ts | 2 +- client/packages/cli/package.json | 16 +- client/packages/cli/src/index.js | 99 +---- client/packages/cli/src/new/commands/claim.ts | 39 ++ .../packages/cli/src/new/commands/explorer.ts | 27 ++ client/packages/cli/src/new/commands/info.ts | 40 ++ client/packages/cli/src/new/commands/init.ts | 73 ++++ .../cli/src/new/commands/initWithoutFiles.ts | 72 ++++ client/packages/cli/src/new/commands/login.ts | 75 ++++ .../packages/cli/src/new/commands/logout.ts | 23 ++ client/packages/cli/src/new/commands/pull.ts | 36 ++ client/packages/cli/src/new/commands/push.ts | 35 ++ client/packages/cli/src/new/commands/query.ts | 58 +++ .../packages/cli/src/new/context/authToken.ts | 92 +++++ .../cli/src/new/context/currentApp.ts | 269 +++++++++++++ .../cli/src/new/context/globalOpts.ts | 22 ++ .../cli/src/new/context/platformApi.ts | 35 ++ .../cli/src/new/context/projectInfo.ts | 179 +++++++++ client/packages/cli/src/new/errors.ts | 7 + client/packages/cli/src/new/index.ts | 332 ++++++++++++++++ client/packages/cli/src/new/layer.ts | 86 +++++ client/packages/cli/src/new/lib/createApp.ts | 18 + client/packages/cli/src/new/lib/handleEnv.ts | 107 +++++ client/packages/cli/src/new/lib/http.ts | 78 ++++ client/packages/cli/src/new/lib/login.ts | 51 +++ client/packages/cli/src/new/lib/pullPerms.ts | 44 +++ client/packages/cli/src/new/lib/pullSchema.ts | 95 +++++ client/packages/cli/src/new/lib/pushPerms.ts | 79 ++++ client/packages/cli/src/new/lib/pushSchema.ts | 207 ++++++++++ client/packages/cli/src/new/lib/ui.ts | 36 ++ client/packages/cli/src/new/logging.ts | 32 ++ client/packages/cli/src/new/program.ts | 3 + client/packages/cli/src/renderSchemaPlan.ts | 2 +- client/packages/cli/src/ui/index.ts | 2 +- client/packages/cli/src/util/getAuthPaths.ts | 1 + .../util/{mergeSchema.js => mergeSchema.ts} | 16 +- client/packages/cli/tsconfig.json | 5 + client/pnpm-lock.yaml | 364 +++++++++++++++++- 38 files changed, 2661 insertions(+), 96 deletions(-) create mode 100644 client/packages/cli/src/new/commands/claim.ts create mode 100644 client/packages/cli/src/new/commands/explorer.ts create mode 100644 client/packages/cli/src/new/commands/info.ts create mode 100644 client/packages/cli/src/new/commands/init.ts create mode 100644 client/packages/cli/src/new/commands/initWithoutFiles.ts create mode 100644 client/packages/cli/src/new/commands/login.ts create mode 100644 client/packages/cli/src/new/commands/logout.ts create mode 100644 client/packages/cli/src/new/commands/pull.ts create mode 100644 client/packages/cli/src/new/commands/push.ts create mode 100644 client/packages/cli/src/new/commands/query.ts create mode 100644 client/packages/cli/src/new/context/authToken.ts create mode 100644 client/packages/cli/src/new/context/currentApp.ts create mode 100644 client/packages/cli/src/new/context/globalOpts.ts create mode 100644 client/packages/cli/src/new/context/platformApi.ts create mode 100644 client/packages/cli/src/new/context/projectInfo.ts create mode 100644 client/packages/cli/src/new/errors.ts create mode 100644 client/packages/cli/src/new/index.ts create mode 100644 client/packages/cli/src/new/layer.ts create mode 100644 client/packages/cli/src/new/lib/createApp.ts create mode 100644 client/packages/cli/src/new/lib/handleEnv.ts create mode 100644 client/packages/cli/src/new/lib/http.ts create mode 100644 client/packages/cli/src/new/lib/login.ts create mode 100644 client/packages/cli/src/new/lib/pullPerms.ts create mode 100644 client/packages/cli/src/new/lib/pullSchema.ts create mode 100644 client/packages/cli/src/new/lib/pushPerms.ts create mode 100644 client/packages/cli/src/new/lib/pushSchema.ts create mode 100644 client/packages/cli/src/new/lib/ui.ts create mode 100644 client/packages/cli/src/new/logging.ts create mode 100644 client/packages/cli/src/new/program.ts rename client/packages/cli/src/util/{mergeSchema.js => mergeSchema.ts} (96%) diff --git a/client/packages/cli/__tests__/e2e/helpers.ts b/client/packages/cli/__tests__/e2e/helpers.ts index fda69259c8..e6f64a50cd 100644 --- a/client/packages/cli/__tests__/e2e/helpers.ts +++ b/client/packages/cli/__tests__/e2e/helpers.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'os'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const CLI_BIN = join(__dirname, '../../bin/index.js'); +const CLI_BIN = join(__dirname, '../../dist/new/index.js'); const apiUrl = process.env.INSTANT_CLI_API_URI || 'https://api.instantdb.com'; diff --git a/client/packages/cli/package.json b/client/packages/cli/package.json index c4fe96d6a2..7ea86559ac 100644 --- a/client/packages/cli/package.json +++ b/client/packages/cli/package.json @@ -18,9 +18,14 @@ } }, "bin": { - "instant-cli": "bin/index.js" + "instant-cli": "bin/index.js", + "new-instant-cli": "dist/new/index.js" }, "dependencies": { + "@commander-js/extra-typings": "^14.0.0", + "@effect/cli": "^0.73.0", + "@effect/platform": "^0.94.1", + "@effect/platform-node": "^0.104.0", "@instantdb/core": "workspace:*", "@instantdb/platform": "workspace:*", "@instantdb/version": "workspace:*", @@ -32,6 +37,7 @@ "commander": "^12.1.0", "dotenv": "^16.3.1", "dotenv-flow": "^4.1.0", + "effect": "^3.19.14", "env-paths": "^3.0.0", "find-up-simple": "^1.0.0", "json-diff": "^1.0.6", @@ -39,6 +45,8 @@ "lodash.throttle": "^4.1.1", "open": "^10.1.0", "package-directory": "^8.1.0", + "package-manager-detector": "^1.6.0", + "pkg-types": "^2.3.0", "prettier": "^3.3.3", "sisteransi": "^1.0.5", "string-width": "^8.1.0", @@ -52,14 +60,16 @@ "test": "vitest --project unit", "test:ci": "vitest run", "publish-package": "pnpm pack && npm publish *.tgz --access public", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "prepare": "effect-language-service patch" }, "devDependencies": { "@babel/core": "^7.17.9", "@babel/preset-env": "^7.16.11", + "@effect/language-service": "^0.64.1", "@types/json-diff": "^1.0.3", "@types/lodash.throttle": "^4.1.9", - "@types/node": "^22.6.1", + "@types/node": "^22.13.0", "npm-run-all": "^4.1.5", "typescript": "^5.9.3", "vitest": "^3.2.4" diff --git a/client/packages/cli/src/index.js b/client/packages/cli/src/index.js index 8031fc40a4..6ffea0c851 100644 --- a/client/packages/cli/src/index.js +++ b/client/packages/cli/src/index.js @@ -393,7 +393,7 @@ program .option('--title ', 'Title for the created app') .action(handleInit); -program +const initWithoutFilesDef = program .command('init-without-files') .description('Generate a new app id and admin token pair without any files.') .option('--title <title>', 'Title for the created app.') @@ -601,7 +601,11 @@ program await handleQuery(queryArg, opts); }); -program.parse(process.argv); +// Only parse the old version of the cli if the command is "instant-cli" +// because this file gets imported places +if (process.argv[1].split('/').pop() === 'instant-cli') { + program.parse(process.argv); +} async function handleInit(opts) { const pkgAndAuthInfo = @@ -884,7 +888,7 @@ async function getOrCreateAppAndWriteToEnv(pkgAndAuthInfo, opts) { async function pull(bag, appId, pkgAndAuthInfo) { if (bag === 'schema' || bag === 'all') { - const { ok } = await pullSchema(appId, pkgAndAuthInfo); + const { ok } = await oldPullSchema(appId, pkgAndAuthInfo); if (!ok) return process.exit(1); } if (bag === 'perms' || bag === 'all') { @@ -1043,80 +1047,6 @@ async function getOrInstallInstantModuleWithErrorLogging(pkgDir, opts) { return moduleName; } -async function promptCreateApp(opts) { - const id = randomUUID(); - const token = randomUUID(); - - let _title; - if (opts?.title) { - _title = opts.title; - } else { - _title = await renderUnwrap( - new UI.TextInput({ - prompt: 'What would you like to call it?', - placeholder: 'My cool app', - }), - ).catch(() => null); - } - - const title = _title?.trim(); - - if (!title) { - error('No name provided.'); - return { ok: false }; - } - - const res = await fetchJson({ - debugName: 'Fetching orgs', - method: 'GET', - path: '/dash', - errorMessage: 'Failed to fetch apps.', - command: 'init', - }); - if (!res.ok) { - return { ok: false }; - } - - const allowedOrgs = res.data.orgs.filter((org) => org.role !== 'app-member'); - - let org_id = opts.org; - - if (!org_id && allowedOrgs.length) { - const choices = [{ label: '(No organization)', value: null }]; - for (const org of allowedOrgs) { - choices.push({ label: org.title, value: org.id }); - } - const choice = await renderUnwrap( - new UI.Select({ - promptText: 'Would you like to create the app in an organization?', - options: choices, - }), - ); - if (choice) { - org_id = choice; - } - } - - const app = { id, title, admin_token: token, org_id }; - const appRes = await fetchJson({ - method: 'POST', - path: '/dash/apps', - debugName: 'App create', - errorMessage: 'Failed to create app.', - body: app, - command: 'init', - }); - - if (!appRes.ok) return { ok: false }; - return { - ok: true, - appId: id, - appTitle: title, - appToken: token, - source: 'created', - }; -} - async function promptImportAppOrCreateApp() { const res = await fetchJson({ debugName: 'Fetching apps', @@ -1353,7 +1283,7 @@ async function getOrPromptPackageAndAuthInfoWithErrorLogging(opts) { return { pkgDir, projectType, instantModuleName, authToken }; } -async function pullSchema( +async function oldPullSchema( appId, { pkgDir, instantModuleName, experimentalTypePreservation }, ) { @@ -1608,7 +1538,8 @@ function jobGroupDescription(jobs) { return joinInSentence([...actions].sort()) || 'updating schema'; } -async function waitForIndexingJobsToFinish(appId, data) { +// TODO: rewrite in effect +export async function waitForIndexingJobsToFinish(appId, data) { const spinnerDefferedPromise = deferred(); const spinner = new UI.Spinner({ promise: spinnerDefferedPromise.promise, @@ -1694,7 +1625,7 @@ async function waitForIndexingJobsToFinish(appId, data) { } } -const resolveRenames = async (created, promptData, extraInfo) => { +export const resolveRenames = async (created, promptData, extraInfo) => { const answer = await renderUnwrap( new ResolveRenamePrompt( created, @@ -2050,7 +1981,7 @@ function prettyPrintJSONErr(data) { } } -async function readLocalPermsFile() { +export async function readLocalPermsFile() { const readCandidates = getPermsReadCandidates(); const res = await loadConfig({ sources: readCandidates, @@ -2071,7 +2002,7 @@ async function readLocalPermsFileWithErrorLogging() { return res; } -async function readLocalSchemaFile() { +export async function readLocalSchemaFile() { const readCandidates = getSchemaReadCandidates(); const res = await loadConfig({ sources: readCandidates, @@ -2082,7 +2013,7 @@ async function readLocalSchemaFile() { return { path: relativePath, schema: res.config }; } -async function readInstantConfigFile() { +export async function readInstantConfigFile() { return ( await loadConfig({ sources: [ @@ -2189,7 +2120,7 @@ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } -function countEntities(o) { +export function countEntities(o) { return Object.keys(o).length; } diff --git a/client/packages/cli/src/new/commands/claim.ts b/client/packages/cli/src/new/commands/claim.ts new file mode 100644 index 0000000000..4485d86abd --- /dev/null +++ b/client/packages/cli/src/new/commands/claim.ts @@ -0,0 +1,39 @@ +import { HttpClient, HttpClientRequest } from '@effect/platform'; +import chalk from 'chalk'; +import { Effect } from 'effect'; +import { CurrentApp } from '../context/currentApp.js'; +import { BadArgsError } from '../errors.js'; +import { WithAppLayer } from '../layer.js'; +import { InstantHttpAuthed } from '../lib/http.js'; + +export const claimCommand = Effect.gen(function* () { + const { appId, adminToken } = yield* CurrentApp; + + yield* Effect.log(`Found app: ${appId}`); + + const http = yield* InstantHttpAuthed; + + if (!adminToken) { + return yield* BadArgsError.make({ message: 'Missing app admin token' }); + } + + yield* http + .pipe( + HttpClient.mapRequestInputEffect( + HttpClientRequest.bodyJson({ + app_id: appId, + token: adminToken, + }), + ), + ) + .post(`/dash/apps/ephemeral/${appId}/claim`); + + yield* Effect.log(chalk.green('App claimed!')); +}).pipe( + Effect.provide( + WithAppLayer({ + coerce: false, + applyEnv: false, + }), + ), +); diff --git a/client/packages/cli/src/new/commands/explorer.ts b/client/packages/cli/src/new/commands/explorer.ts new file mode 100644 index 0000000000..323eaa1c40 --- /dev/null +++ b/client/packages/cli/src/new/commands/explorer.ts @@ -0,0 +1,27 @@ +import { Effect } from 'effect'; +import openInBrowser from 'open'; +import { explorerDef, OptsFromCommand } from '../index.js'; +import { CurrentApp } from '../context/currentApp.js'; +import { WithAppLayer } from '../layer.js'; +import { getDashUrl } from '../lib/http.js'; + +export const explorerCmd = (opts: OptsFromCommand<typeof explorerDef>) => + Effect.gen(function* () { + const { appId } = yield* CurrentApp; + const dashUrl = yield* getDashUrl; + const url = `${dashUrl}/dash?s=main&app=${appId}&t=explorer`; + yield* Effect.tryPromise(() => openInBrowser(url)).pipe( + Effect.catchAll(() => + Effect.log( + `Failed to open Explorer in browser\nOpen Explorer manually:\n${url}`, + ), + ), + ); + }).pipe( + Effect.provide( + WithAppLayer({ + coerce: true, + appId: opts.app, + }), + ), + ); diff --git a/client/packages/cli/src/new/commands/info.ts b/client/packages/cli/src/new/commands/info.ts new file mode 100644 index 0000000000..a465f6951f --- /dev/null +++ b/client/packages/cli/src/new/commands/info.ts @@ -0,0 +1,40 @@ +import { HttpClientResponse } from '@effect/platform'; +import { Effect, Layer, pipe, Schema, Option } from 'effect'; +import { AuthLayerLive } from '../layer.js'; +import { InstantHttpAuthed } from '../lib/http.js'; +import { version } from '@instantdb/version'; +import { AuthToken } from '../context/authToken.js'; + +const DashMeResponse = Schema.Struct({ + user: Schema.Struct({ + id: Schema.String, + email: Schema.String, + created_at: Schema.String, + }), +}); + +export const infoCommand = () => + Effect.gen(function* () { + const http = yield* Effect.serviceOption(InstantHttpAuthed).pipe( + Effect.map(Option.getOrNull), + ); + + yield* Effect.log('CLI Version:', version); + // If logged in.. + if (http) { + const meData = yield* http.get('/dash/me').pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(DashMeResponse)), + Effect.mapError( + (e) => new Error("Couldn't get user information.", { cause: e }), + ), + ); + + yield* Effect.log(`Logged in as ${meData.user.email}`); + } else { + yield* Effect.log('Not logged in.'); + } + }).pipe( + Effect.provide( + AuthLayerLive(false).pipe(Layer.catchAll((e) => Layer.empty)), + ), + ); diff --git a/client/packages/cli/src/new/commands/init.ts b/client/packages/cli/src/new/commands/init.ts new file mode 100644 index 0000000000..2e7da0df8f --- /dev/null +++ b/client/packages/cli/src/new/commands/init.ts @@ -0,0 +1,73 @@ +import { Effect, Option } from 'effect'; +import { OptsFromCommand, initDef } from '../index.js'; +import { ProjectInfo } from '../context/projectInfo.js'; +import { CurrentApp } from '../context/currentApp.js'; +import { WithAppLayer } from '../layer.js'; +import { readLocalPermsFile, readLocalSchemaFile } from '../../index.js'; +import { pullSchema } from '../lib/pullSchema.js'; +import { pullPerms } from '../lib/pullPerms.js'; +import { promptOk } from '../lib/ui.js'; +import { pushSchema } from '../lib/pushSchema.js'; +import { pushPerms } from '../lib/pushPerms.js'; + +export const initCommand = (options: OptsFromCommand<typeof initDef>) => + Effect.gen(function* () { + const _info = yield* ProjectInfo; + const _app = yield* CurrentApp; + + yield* Effect.matchEffect( + Effect.tryPromise(readLocalSchemaFile).pipe( + // Throws NoSuchElementException if no file found + Effect.flatMap(Option.fromNullable), + ), + { + onFailure: () => pullSchema({ experimentalTypePreservation: false }), + onSuccess: () => + Effect.gen(function* () { + const doSchemaPush = yield* promptOk( + { + promptText: 'Found local schema. Push it to the new app?', + inline: true, + }, + false, + ); + if (doSchemaPush) { + yield* pushSchema(); + } + }), + }, + ); + + yield* Effect.matchEffect( + Effect.tryPromise(readLocalPermsFile).pipe( + // Throws NoSuchElementException if no file found + Effect.flatMap(Option.fromNullable), + ), + { + onFailure: () => pullPerms, + onSuccess: () => + Effect.gen(function* () { + const doPermsPush = yield* promptOk( + { + promptText: 'Found local perms. Push it to the new app?', + inline: true, + }, + false, + ); + if (doPermsPush) { + yield* pushPerms; + } + }), + }, + ); + }).pipe( + Effect.provide( + WithAppLayer({ + coerce: true, + title: options.title, + appId: options.app, + packageName: options.package as any, + applyEnv: true, + }), + ), + ); diff --git a/client/packages/cli/src/new/commands/initWithoutFiles.ts b/client/packages/cli/src/new/commands/initWithoutFiles.ts new file mode 100644 index 0000000000..a183725838 --- /dev/null +++ b/client/packages/cli/src/new/commands/initWithoutFiles.ts @@ -0,0 +1,72 @@ +import { Effect } from 'effect'; +import { PlatformApi } from '../context/platformApi.js'; +import { BadArgsError } from '../errors.js'; +import { OptsFromCommand, initWithoutFilesDef } from '../index.js'; +import { createApp } from '../lib/createApp.js'; +import { AuthTokenLive } from '../context/authToken.js'; +import { AuthLayerLive } from '../layer.js'; +import chalk from 'chalk'; + +export const initWithoutFilesCommand = Effect.fn(function* ( + opts: OptsFromCommand<typeof initWithoutFilesDef>, +) { + if (!opts?.title) { + return yield* BadArgsError.make({ + message: 'Title is required for creating a new app without local files.', + }); + } + + if (opts.title.startsWith('-')) { + return yield* BadArgsError.make({ + message: `Invalid title: "${opts.title}". Title cannot be a flag.`, + }); + } + + if (opts?.temp && opts?.orgId) { + return yield* BadArgsError.make({ + message: 'Cannot use --temp and --org-id flags together.', + }); + } + + if (!opts.temp) { + const app = yield* createApp(opts.title, opts.orgId).pipe( + Effect.provide(AuthLayerLive(false)), + ); + console.error(`${chalk.green('Successfully created new app!')}\n`); + yield* Effect.log( + JSON.stringify( + { + app: { + appId: app.app.id, + adminToken: app.app['admin-token'], + }, + error: null, + }, + null, + 2, + ), + ); + } else { + const platform = yield* PlatformApi; + const app = yield* platform.use((api) => + api.createTemporaryApp({ + title: opts.title!, + }), + ); + + console.error(`${chalk.green('Successfully created new app!')}\n`); + yield* Effect.log( + JSON.stringify( + { + app: { + appId: app.app.id, + adminToken: app.app.adminToken, + }, + error: null, + }, + null, + 2, + ), + ); + } +}); diff --git a/client/packages/cli/src/new/commands/login.ts b/client/packages/cli/src/new/commands/login.ts new file mode 100644 index 0000000000..fd440521a4 --- /dev/null +++ b/client/packages/cli/src/new/commands/login.ts @@ -0,0 +1,75 @@ +import chalk from 'chalk'; +import { Effect } from 'effect'; +import openInBrowser from 'open'; +import { OptsFromCommand, loginDef } from '../index.js'; +import { getDashUrl } from '../lib/http.js'; +import { + getLoginTicketAndSecret, + saveConfigAuthToken, + waitForAuthToken, +} from '../lib/login.js'; +import { promptOk } from '../lib/ui.js'; + +const isHeadLessEnvironment = (opts: OptsFromCommand<typeof loginDef>) => { + const noBrowserMode = Boolean( + process.env.INSTANT_CLI_NO_BROWSER || process.env.CI || opts?.headless, + ); + + // Check for common headless environment indicators + return ( + noBrowserMode || + process.env.TERM === 'dumb' || + process.env.SSH_CONNECTION !== undefined || + process.env.SSH_CLIENT !== undefined || + (!process.env.DISPLAY && process.platform === 'linux') || + process.env.WSL_DISTRO_NAME !== undefined + ); +}; + +export const loginCommand = Effect.fn(function* ( + opts: OptsFromCommand<typeof loginDef>, +) { + yield* Effect.log("Let's log you in!"); + + const loginInfo = yield* getLoginTicketAndSecret; + const { secret, ticket } = loginInfo; + const dashOrigin = yield* getDashUrl; + yield* Effect.log(); + // TODO: flip these so rejecting the prompt prints url + if (isHeadLessEnvironment(opts)) { + yield* Effect.log( + `Open this URL in a browser to log in:\n ${dashOrigin}/dash?ticket=${ticket}\n`, + ); + } else { + const ok = yield* promptOk( + { + promptText: + 'This will open instantdb.com in your browser, OK to proceed?', + }, + true, + ); + if (!ok) { + process.exit(1); + } + yield* Effect.tryPromise(() => + openInBrowser(`${dashOrigin}/dash?ticket=${ticket}`), + ); + } + + yield* Effect.log('Waiting for authentication...'); + + const result = yield* waitForAuthToken(secret); + const { token, email } = result; + if (opts.print) { + yield* Effect.log( + chalk.red('[Do not share] Your Instant auth token:', token), + ); + } else { + yield* saveConfigAuthToken(token); + yield* Effect.log(chalk.green(`Successfully logged in as ${email}!`)); + } + return { + authToken: token, + source: 'file' as const, + }; +}); diff --git a/client/packages/cli/src/new/commands/logout.ts b/client/packages/cli/src/new/commands/logout.ts new file mode 100644 index 0000000000..760ad44b6b --- /dev/null +++ b/client/packages/cli/src/new/commands/logout.ts @@ -0,0 +1,23 @@ +import { Effect } from 'effect'; +import { getAuthPaths } from '../../util/getAuthPaths.js'; +import { FileSystem } from '@effect/platform'; +import chalk from 'chalk'; +import { SystemError } from '@effect/platform/Error'; + +export const logoutCommand = Effect.fn(function* () { + const { authConfigFilePath } = getAuthPaths(); + const fs = yield* FileSystem.FileSystem; + + yield* Effect.matchEffect(fs.remove(authConfigFilePath), { + onFailure: (e) => + Effect.gen(function* () { + if (e instanceof SystemError && e.reason === 'NotFound') { + yield* Effect.log(chalk.green('You were already logged out!')); + } else { + yield* Effect.logError(chalk.red('Failed to logout: ' + e.message)); + } + }), + onSuccess: () => + Effect.log(chalk.green('Successfully logged out from Instant!')), + }); +}); diff --git a/client/packages/cli/src/new/commands/pull.ts b/client/packages/cli/src/new/commands/pull.ts new file mode 100644 index 0000000000..6755628234 --- /dev/null +++ b/client/packages/cli/src/new/commands/pull.ts @@ -0,0 +1,36 @@ +import { Effect } from 'effect'; +import { OptsFromCommand, pullDef } from '../index.js'; +import { pullSchema } from '../lib/pullSchema.js'; +import { WithAppLayer } from '../layer.js'; +import { pullPerms } from '../lib/pullPerms.js'; + +export type SchemaPermsOrBoth = 'schema' | 'perms' | 'all'; + +export const pullCommand = ( + arg: SchemaPermsOrBoth, + opts: OptsFromCommand<typeof pullDef>, +) => + Effect.gen(function* () { + arg ||= 'all'; + if (arg === 'schema' || arg === 'all') { + yield* pullSchema({ + experimentalTypePreservation: opts.experimentalTypePreservation, + }); + } + if (arg === 'perms' || arg === 'all') { + yield* pullPerms; + } + }).pipe( + Effect.provide( + WithAppLayer({ + coerce: true, + packageName: opts.package as + | 'react' + | 'react-native' + | 'core' + | 'admin' + | undefined, + appId: opts.app, + }), + ), + ); diff --git a/client/packages/cli/src/new/commands/push.ts b/client/packages/cli/src/new/commands/push.ts new file mode 100644 index 0000000000..69d083fc5d --- /dev/null +++ b/client/packages/cli/src/new/commands/push.ts @@ -0,0 +1,35 @@ +import { Effect } from 'effect'; +import { OptsFromCommand, pushDef } from '../index.js'; +import { WithAppLayer } from '../layer.js'; +import { PACKAGE_ALIAS_AND_FULL_NAMES } from '../context/projectInfo.js'; +import { pushPerms } from '../lib/pushPerms.js'; +import { pushSchema } from '../lib/pushSchema.js'; +import { BadArgsError } from '../errors.js'; + +export const pushCommand = ( + arg: string | undefined, + opts: OptsFromCommand<typeof pushDef>, +) => + Effect.gen(function* () { + arg ||= 'all'; + if (arg !== 'schema' && arg !== 'perms' && arg !== 'all') { + return yield* new BadArgsError({ + message: `Invalid argument: ${arg}. Expected one of schema, perms, or all`, + }); + } + if (arg === 'schema' || arg === 'all') { + yield* pushSchema(opts.rename); + } + if (arg === 'perms' || arg === 'all') { + yield* pushPerms; + } + }).pipe( + Effect.provide( + WithAppLayer({ + coerce: true, + appId: opts.app, + applyEnv: true, + packageName: opts.package as keyof typeof PACKAGE_ALIAS_AND_FULL_NAMES, + }), + ), + ); diff --git a/client/packages/cli/src/new/commands/query.ts b/client/packages/cli/src/new/commands/query.ts new file mode 100644 index 0000000000..41cc5c57b1 --- /dev/null +++ b/client/packages/cli/src/new/commands/query.ts @@ -0,0 +1,58 @@ +import { Effect } from 'effect'; +import JSON5 from 'json5'; +import { OptsFromCommand, queryDef } from '../index.js'; +import { CurrentApp } from '../context/currentApp.js'; +import { WithAppLayer } from '../layer.js'; +import { BadArgsError } from '../errors.js'; +import { InstantHttpAuthed, withCommand } from '../lib/http.js'; +import { HttpBody } from '@effect/platform'; + +export const queryCmd = (arg: string, opts: OptsFromCommand<typeof queryDef>) => + Effect.gen(function* () { + const { appId } = yield* CurrentApp; + const contextCount = + (opts.admin ? 1 : 0) + (opts.asEmail ? 1 : 0) + (opts.asGuest ? 1 : 0); + if (contextCount === 0) { + return yield* BadArgsError.make({ + message: + 'Please specify a context: --admin, --as-email <email>, or --as-guest', + }); + } + if (contextCount > 1) { + return yield* BadArgsError.make({ + message: + 'Please specify only one context: --admin, --as-email <email>, or --as-guest', + }); + } + + const query = yield* Effect.try(() => JSON5.parse(arg)).pipe( + Effect.mapError((e) => + BadArgsError.make({ + message: `Invalid query: ${e.cause}`, + }), + ), + ); + const headers = { 'app-id': appId }; + if (opts.asEmail) { + headers['as-email'] = opts.asEmail; + } else if (opts.asGuest) { + headers['as-guest'] = 'true'; + } + + const http = (yield* InstantHttpAuthed).pipe(withCommand('query')); + const response = yield* http.post('/admin/query', { + headers, + body: HttpBody.unsafeJson({ + query, + }), + }); + const body = yield* response.json; + yield* Effect.log(JSON.stringify(body, null, 2)); + }).pipe( + Effect.provide( + WithAppLayer({ + coerce: false, + appId: opts.app, + }), + ), + ); diff --git a/client/packages/cli/src/new/context/authToken.ts b/client/packages/cli/src/new/context/authToken.ts new file mode 100644 index 0000000000..0c2843353a --- /dev/null +++ b/client/packages/cli/src/new/context/authToken.ts @@ -0,0 +1,92 @@ +import { FileSystem } from '@effect/platform'; +import { Config, Context, Effect, Layer, Option, Schema } from 'effect'; +import envPaths from 'env-paths'; +import { join } from 'path'; +import { loginCommand } from '../commands/login.js'; +import { program } from '../program.js'; + +export class AuthToken extends Context.Tag('instant-cli/new/context/authToken')< + AuthToken, + { + authToken: string; + source: 'admin' | 'env' | 'opt' | 'file'; + } +>() {} + +export class NotAuthedError extends Schema.TaggedError<NotAuthedError>( + 'NotAuthedError', +)('NotAuthedError', { + message: Schema.String, +}) {} + +export const authTokenGetEffect = (allowAdminToken: boolean = true) => + Effect.gen(function* () { + const options = program.opts() as Record<string, any>; + if (typeof options.token === 'string') { + return { + authToken: options.token, + source: 'opt' as 'env' | 'opt' | 'file', + }; + } + + const env = yield* Config.string('INSTANT_CLI_AUTH_TOKEN').pipe( + Config.option, + Config.map(Option.getOrNull), + ); + + if (env) { + return { + authToken: env, + source: 'env' as 'env' | 'opt' | 'file', + }; + } + + const authPaths = yield* getAuthPaths; + const fs = yield* FileSystem.FileSystem; + const file = yield* fs + .readFileString(authPaths.authConfigFilePath, 'utf8') + .pipe( + // will usually fail if file not found, return null instead + Effect.orElseSucceed(() => null), + ); + if (file) { + return { + authToken: file, + source: 'file' as 'env' | 'opt' | 'file', + }; + } + + const secondaryEnv = yield* Config.string('INSTANT_APP_ADMIN_TOKEN').pipe( + Config.orElse(() => Config.string('INSTANT_ADMIN_TOKEN')), + Config.option, + Config.map(Option.getOrNull), + ); + if (secondaryEnv && allowAdminToken) { + return { + authToken: secondaryEnv, + source: 'admin' as 'admin', + }; + } + return yield* NotAuthedError.make({ message: 'You are not logged in' }); + }); + +export const AuthTokenLive = (allowAdminToken: boolean = true) => + Layer.effect(AuthToken, authTokenGetEffect(allowAdminToken)); + +export const AuthTokenCoerceLive = (allowAdminToken: boolean = true) => + Layer.effect( + AuthToken, + authTokenGetEffect(allowAdminToken).pipe( + Effect.catchTag('NotAuthedError', () => loginCommand({})), + ), + ); + +const getAuthPaths = Effect.gen(function* () { + const dev = yield* Config.boolean('INSTANT_CLI_DEV').pipe( + Config.withDefault(false), + ); + const key = `instantdb-${dev ? 'dev' : 'prod'}`; + const { config: appConfigDirPath } = envPaths(key); + const authConfigFilePath = join(appConfigDirPath, 'a'); + return { authConfigFilePath, appConfigDirPath }; +}); diff --git a/client/packages/cli/src/new/context/currentApp.ts b/client/packages/cli/src/new/context/currentApp.ts new file mode 100644 index 0000000000..281a8558ab --- /dev/null +++ b/client/packages/cli/src/new/context/currentApp.ts @@ -0,0 +1,269 @@ +import { + HttpBody, + HttpClientRequest, + HttpClientResponse, +} from '@effect/platform'; +import { randomUUID } from 'crypto'; +import { Context, Data, Effect, Layer, Runtime, Schema, Option } from 'effect'; +import { UI } from '../../ui/index.js'; +import { handleEnv } from '../lib/handleEnv.js'; +import { + getBaseUrl, + InstantHttp, + InstantHttpAuthed, + withCommand, +} from '../lib/http.js'; +import { runUIEffect } from '../lib/ui.js'; +import { AuthToken } from './authToken.js'; +import { GlobalOpts } from './globalOpts.js'; +import { PlatformApi } from './platformApi.js'; +import { readInstantConfigFile } from '../../index.js'; +import { BadArgsError } from '../errors.js'; + +export type CurrentAppInfo = { + appId: string; + adminToken?: string; + source: 'create' | 'import' | 'env' | 'flag' | 'ephemeral'; +}; + +export class CurrentApp extends Context.Tag( + 'instant-cli/new/context/currentApp', +)<CurrentApp, CurrentAppInfo>() {} + +function isUUID(uuid: string) { + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return uuidRegex.test(uuid); +} + +export class CurrentAppContextError extends Data.TaggedError( + 'CurrentAppContextError', +)<{ + message: string; +}> {} + +export class AppNotFoundError extends Data.TaggedError('AppNotFoundError')<{ + message: string; +}> {} + +export const potentialEnvs: Record<string, string> = { + catchall: 'INSTANT_APP_ID', + next: 'NEXT_PUBLIC_INSTANT_APP_ID', + svelte: 'PUBLIC_INSTANT_APP_ID', + vite: 'VITE_INSTANT_APP_ID', + expo: 'EXPO_PUBLIC_INSTANT_APP_ID', + nuxt: 'NUXT_PUBLIC_INSTANT_APP_ID', + bun: 'BUN_PUBLIC_INSTANT_APP_ID', +}; + +const potentialAdminTokenEnvs = { + default: 'INSTANT_APP_ADMIN_TOKEN', + short: 'INSTANT_ADMIN_TOKEN', +} as const; + +// TODO: add instant.config.ts support +export const CurrentAppLive = (args: { + appId?: string; + coerce?: boolean; + title?: string; + applyEnv?: boolean; +}) => + Layer.effect( + CurrentApp, + Effect.gen(function* () { + if (args.appId) { + if (isUUID(args.appId)) { + return { + appId: args.appId, + source: 'flag' as const, + }; + } else { + // Check for instant.config.ts + const config = yield* Effect.tryPromise(() => + readInstantConfigFile(), + ); + const nameMatch = config?.apps?.[args.appId]; + if (!nameMatch.id) { + return yield* BadArgsError.make({ + message: 'App ID not found in app inside instant.config.ts', + }); + } + if (nameMatch) { + return { + appId: nameMatch.id, + source: 'flag' as const, + }; + } + } + } + + // Detect from ENV + const found = Object.keys(potentialEnvs) + .map((type) => { + const envName = potentialEnvs[type]; + const value = process.env[envName]; + return { type, envName, value }; + }) + .find(({ value }) => !!value); + + if (found?.value && !isUUID(found.value)) { + return yield* new CurrentAppContextError({ + message: `Invalid UUID: ${found.value}`, + }); + } else if (found?.value) { + // try to get admin token + const adminToken = yield* getAdminToken; + return { + adminToken: Option.getOrUndefined(adminToken), + appId: found?.value, + source: 'env' as const, + }; + } + return yield* new AppNotFoundError({ + message: 'No app found', + }); + }).pipe( + // coerce into new app if app not found + Effect.catchTag('AppNotFoundError', () => + Effect.gen(function* () { + if (!args.coerce) + return yield* new AppNotFoundError({ message: 'No app found' }); + + // coerce into a new app + const globalOpts = yield* GlobalOpts; + if (globalOpts.yes) { + if (!args.title) { + return yield* new CurrentAppContextError({ + message: `Title is required when using --yes and no app is linked`, + }); + } else { + yield* Effect.log(`Creating app with title: ${args.title}`); + return yield* createApp(args.title); + } + } + + return yield* promptImportOrCreateApp; + }), + ), + + // Handle save env + Effect.tap((app) => + Effect.gen(function* () { + if ( + args.applyEnv && + (app.source === 'import' || app.source == 'create') + ) { + yield* handleEnv(app); + } + }), + ), + ), + ); + +const createApp = Effect.fn(function* (title: string, orgId?: string) { + const id = randomUUID(); + const token = randomUUID(); + const app = { id, title, admin_token: token, org_id: orgId }; + + const http = yield* InstantHttpAuthed; + yield* HttpClientRequest.post('/dash/apps').pipe( + HttpClientRequest.bodyJson(app), + Effect.flatMap(http.execute), + ); + return { + appId: id, + source: 'create', + adminToken: token, + } satisfies CurrentAppInfo; +}); + +const promptImportOrCreateApp = Effect.gen(function* () { + const api = yield* getSimpleApi; + const result = yield* runUIEffect( + new UI.AppSelector({ + startingMenuIndex: 0, + allowEphemeral: true, + allowCreate: true, + api, + }), + ); + + if (result.approach === 'import') { + yield* Effect.fork(trackAppImport(result.appId)); + } + + return { + appId: result.appId, + source: result.approach, + adminToken: result.adminToken, + } satisfies CurrentAppInfo; +}); + +const getSimpleApi = Effect.gen(function* () { + const effectRuntime = yield* Effect.runtime<never>(); + + const http = yield* InstantHttpAuthed; + const dashData = yield* http + .get('/dash') + .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Schema.Any))); + const platform = yield* PlatformApi; + + const baseUrl = yield* getBaseUrl; + const { authToken } = yield* AuthToken; + + return { + getDash: () => dashData, + createApp: async (title, orgId) => { + return Runtime.runPromise( + effectRuntime, + createApp(title, orgId).pipe( + Effect.provideService(InstantHttpAuthed, http), + ), + ); + }, + + createEphemeralApp: async (title) => { + return await Runtime.runPromise( + effectRuntime, + Effect.gen(function* () { + const platform = yield* PlatformApi; + const response = yield* platform.use( + (p) => p.createTemporaryApp({ title: title }), + 'Error creating temporary app', + ); + return { + appId: response.app.id, + adminToken: response.app.adminToken, + }; + }).pipe(Effect.provideService(PlatformApi, platform)), + ); + }, + + async getAppsForOrg(orgId) { + const response = await fetch(baseUrl + '/dash/orgs/' + orgId, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + const data = await response.json(); + return { apps: data.apps }; + }, + } satisfies UI.AppSelectorApi; +}); + +const getAdminToken = Effect.gen(function* () { + const found = Object.keys(potentialAdminTokenEnvs) + .map((type) => { + const envName = potentialAdminTokenEnvs[type]; + const value = process.env[envName]; + return { type, envName, value }; + }) + .find(({ value }) => !!value); + return Option.fromNullable(found?.value); +}); + +const trackAppImport = (appId: string) => + Effect.gen(function* () { + const http = (yield* InstantHttpAuthed).pipe(withCommand('init')); + yield* http.post(`/dash/apps/${appId}/import`); + }); diff --git a/client/packages/cli/src/new/context/globalOpts.ts b/client/packages/cli/src/new/context/globalOpts.ts new file mode 100644 index 0000000000..bba2836d65 --- /dev/null +++ b/client/packages/cli/src/new/context/globalOpts.ts @@ -0,0 +1,22 @@ +import { Context, Effect, Layer } from 'effect'; +import { program } from '../program.js'; + +export class GlobalOpts extends Context.Tag( + 'instant-cli/new/context/globalOpts', +)< + GlobalOpts, + { + token?: string; + yes: boolean; + env?: string; + } +>() {} + +export const GlobalOptsLive = Layer.sync(GlobalOpts, () => { + const opts = program.optsWithGlobals() as Record<string, any>; + return { + yes: opts?.yes || false, + token: opts?.token, + env: opts?.env, + }; +}); diff --git a/client/packages/cli/src/new/context/platformApi.ts b/client/packages/cli/src/new/context/platformApi.ts new file mode 100644 index 0000000000..28509748e7 --- /dev/null +++ b/client/packages/cli/src/new/context/platformApi.ts @@ -0,0 +1,35 @@ +import { PlatformApi as InstantPlatformApi } from '@instantdb/platform'; +import { Data, Effect } from 'effect'; +import { getBaseUrl } from '../lib/http.js'; + +export class PlatformApiError extends Data.TaggedError('PlatformApiError')<{ + message: string; + cause: unknown; +}> {} + +export class PlatformApi extends Effect.Service<PlatformApi>()( + 'instant-cli/new/context/platformApi', + { + effect: Effect.gen(function* () { + const origin = yield* getBaseUrl; + const apiClient = new InstantPlatformApi({ + apiURI: origin, + }); + + return { + use: <R>( + fun: (api: typeof apiClient) => Promise<R>, + errorMessage?: string, + ) => + Effect.tryPromise({ + try: (_signal) => fun(apiClient), + catch: (e) => + new PlatformApiError({ + message: errorMessage || 'Error using platform api', + cause: e, + }), + }), + }; + }), + }, +) {} diff --git a/client/packages/cli/src/new/context/projectInfo.ts b/client/packages/cli/src/new/context/projectInfo.ts new file mode 100644 index 0000000000..7896b8919e --- /dev/null +++ b/client/packages/cli/src/new/context/projectInfo.ts @@ -0,0 +1,179 @@ +import { Context, Data, Effect, Layer } from 'effect'; +import { detect } from 'package-manager-detector/detect'; +import { PackageJson, readPackage } from 'pkg-types'; + +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { UI } from '../../ui/index.js'; +import { findProjectDir } from '../../util/projectDir.js'; +import { runUIEffect } from '../lib/ui.js'; +import chalk from 'chalk'; + +export class ProjectInfo extends Context.Tag( + 'instant-cli/new/context/projectInfo', +)< + ProjectInfo, + { + pkgDir: string; + projectType: 'node' | 'deno'; + instantModuleName: string; + } +>() {} + +const execAsync = promisify(exec); + +export const PACKAGE_ALIAS_AND_FULL_NAMES = { + react: '@instantdb/react', + 'react-native': '@instantdb/react-native', + core: '@instantdb/core', + admin: '@instantdb/admin', + solid: '@instantdb/solidjs', +}; + +export class ProjectInfoError extends Data.TaggedError('ProjectInfoError')<{ + message: string; + cause?: unknown; +}> {} + +const getProjectInfo = ( + coerce: boolean = true, + packageName?: keyof typeof PACKAGE_ALIAS_AND_FULL_NAMES, +) => + Effect.gen(function* () { + const projectDir = yield* Effect.tryPromise({ + try: () => findProjectDir(), + catch: (e) => + new ProjectInfoError({ message: "Couldn't get project dir" }), + }); + + if (!projectDir) { + return yield* new ProjectInfoError({ + message: + "Couldn't find a project directory (package.json). Is there a package.json or deno.json file", + }); + } + + if (projectDir.type === 'deno') { + return { + pkgDir: projectDir.dir, + projectType: projectDir.type, + instantModuleName: '@instantdb/core', + }; + } + + const pkgJson = yield* Effect.tryPromise({ + try: () => readPackage(), + catch: () => + new ProjectInfoError({ message: "Couldn't read package.json" }), + }); + + yield* Effect.log('Checking for an Instant SDK...'); + let moduleName = getInstantModuleName(pkgJson); + if (!moduleName && !coerce) { + return yield* new ProjectInfoError({ + message: 'No instant client library installed', + }); + } + yield* Effect.log( + `Found ${chalk.green(moduleName)} in your package.json.\n`, + ); + + // TODO: Clean up with option + const packageManager = yield* Effect.tryPromise(() => detect()).pipe( + Effect.flatMap(Effect.fromNullable), + Effect.mapError( + () => + new ProjectInfoError({ + message: 'Failed to detect package manager', + }), + ), + ); + + if (!moduleName && coerce) { + // install the packages + if (packageName) { + moduleName = PACKAGE_ALIAS_AND_FULL_NAMES[packageName]; + } else { + moduleName = yield* runUIEffect( + new UI.Select({ + promptText: 'Which package would you like to use?', + options: [ + { label: '@instantdb/react', value: '@instantdb/react' }, + { + label: '@instantdb/react-native', + value: '@instantdb/react-native', + }, + { label: '@instantdb/core', value: '@instantdb/core' }, + { label: '@instantdb/admin', value: '@instantdb/admin' }, + ], + }), + ).pipe( + Effect.flatMap(Effect.fromNullable), + Effect.mapError( + () => + new ProjectInfoError({ + message: 'Failed to select package', + }), + ), + ); + } + const packagesToInstall = [moduleName]; + if (moduleName === '@instantdb/react-native') { + packagesToInstall.push( + 'react-native-get-random-values', + '@react-native-async-storage/async-storage', + ); + } + const installCommand = getInstallCommand( + packageManager.agent, + packagesToInstall.join(' '), + ); + yield* Effect.log(installCommand); + yield* runUIEffect( + new UI.Spinner({ + promise: execAsync(installCommand, { + cwd: projectDir.dir, + }), + errorText: 'Failed to install packages', + workingText: `Installing ${packagesToInstall.join(', ')} using ${packageManager.agent}...`, + doneText: `Installed ${packagesToInstall.join(', ')} using ${packageManager.agent}.`, + }), + ); + return { + pkgDir: projectDir.dir, + projectType: projectDir.type, + instantModuleName: moduleName, + }; + } else { + return { + pkgDir: projectDir.dir, + projectType: projectDir.type, + instantModuleName: moduleName!, + }; + } + }); + +export const ProjectInfoLive = ( + coerce: boolean = true, + packageName?: keyof typeof PACKAGE_ALIAS_AND_FULL_NAMES, +) => Layer.effect(ProjectInfo, getProjectInfo(coerce, packageName)); + +function getInstantModuleName(pkgJson: PackageJson) { + const deps = pkgJson.dependencies || {}; + const devDeps = pkgJson.devDependencies || {}; + const instantModuleName = [ + '@instantdb/react', + '@instantdb/react-native', + '@instantdb/core', + '@instantdb/admin', + ].find((name) => deps[name] || devDeps[name]); + return instantModuleName; +} + +function getInstallCommand(packageManager: string, moduleName: string) { + if (packageManager === 'npm') { + return `npm install ${moduleName}`; + } else { + return `${packageManager} add ${moduleName}`; + } +} diff --git a/client/packages/cli/src/new/errors.ts b/client/packages/cli/src/new/errors.ts new file mode 100644 index 0000000000..c8c6719db4 --- /dev/null +++ b/client/packages/cli/src/new/errors.ts @@ -0,0 +1,7 @@ +import { Schema } from 'effect'; + +export class BadArgsError extends Schema.TaggedError<BadArgsError>( + 'BadArgsError', +)('BadArgsError', { + message: Schema.String, +}) {} diff --git a/client/packages/cli/src/new/index.ts b/client/packages/cli/src/new/index.ts new file mode 100644 index 0000000000..13c0315414 --- /dev/null +++ b/client/packages/cli/src/new/index.ts @@ -0,0 +1,332 @@ +import { Command, Option } from '@commander-js/extra-typings'; +import chalk from 'chalk'; +import { Effect } from 'effect'; +import version from '../version.js'; +import { initCommand } from './commands/init.js'; +import { initWithoutFilesCommand } from './commands/initWithoutFiles.js'; +import { loginCommand } from './commands/login.js'; +import { logoutCommand } from './commands/logout.js'; +import { loadEnv } from '../util/loadEnv.js'; +import { BaseLayerLive, printRedErrors, runCommandEffect } from './layer.js'; +import { infoCommand } from './commands/info.js'; +import { pullCommand, SchemaPermsOrBoth } from './commands/pull.js'; +import { claimCommand } from './commands/claim.js'; +import { pushCommand } from './commands/push.js'; +import { explorerCmd } from './commands/explorer.js'; +import { queryCmd } from './commands/query.js'; +import { program } from './program.js'; + +loadEnv(); + +export type OptsFromCommand<C> = + C extends Command<any, infer R, any> ? R : never; + +program + .name('instant-cli') + .addOption(globalOption('-t --token <token>', 'Auth token override')) + .addOption(globalOption('-y --yes', "Answer 'yes' to all prompts")) + .addOption(globalOption('--env <file>', 'Use a specific .env file')) + .addOption( + globalOption('-v --version', 'Print the version number', () => { + console.log(version); + process.exit(0); + }), + ) + .addHelpOption(globalOption('-h --help', 'Print the help text for a command')) + .usage(`<command> ${chalk.dim('[options] [args]')}`); + +// Command List +export const initDef = program + .command('init') + .description('Set up a new project.') + .option( + '-a --app <app-id>', + 'If you have an existing app ID, we can pull schema and perms from there.', + ) + .option( + '-p --package <react|react-native|core|admin>', + 'Which package to automatically install if there is not one installed already.', + ) + .option('--title <title>', 'Title for the created app') + .action((options) => { + return runCommandEffect(initCommand(options)); + }); + +export const initWithoutFilesDef = program + .command('init-without-files') + .description('Generate a new app id and admin token pair without any files.') + .option('--title <title>', 'Title for the created app.') + .option( + '--org-id <org-id>', + 'Organization id for app. Cannot be used with --temp flag.', + ) + .option( + '--temp', + 'Create a temporary app which will automatically delete itself after >24 hours.', + ) + .action((opts) => { + return runCommandEffect( + initWithoutFilesCommand(opts).pipe(Effect.provide(BaseLayerLive)), + ); + }); + +export const loginDef = program + .command('login') + .description('Log into your account') + .option('-p --print', 'Prints the auth token into the console.') + .option( + '--headless', + 'Print the login URL instead of trying to open the browser', + ) + .action(async (opts) => { + runCommandEffect(loginCommand(opts).pipe(Effect.provide(BaseLayerLive))); + }); + +const _logoutDef = program + .command('logout') + .description('Log out of your Instant account') + .action(async () => { + runCommandEffect(logoutCommand().pipe(Effect.provide(BaseLayerLive))); + }); + +export const infoDef = program + .command('info') + .description('Display CLI version and login status') + .action(async () => { + runCommandEffect(infoCommand()); + }); + +export const explorerDef = program + .command('explorer') + .description('Opens the Explorer in your browser') + .option( + '-a --app <app-id>', + 'App ID to open the explorer to. Defaults to *_INSTANT_APP_ID in .env', + ) + .action(async (opts) => { + runCommandEffect(explorerCmd(opts)); + }); + +export const queryDef = program + .command('query') + .argument('<query>', 'InstaQL query as JSON/JSON5') + .option( + '-a --app <app-id>', + 'App ID to query. Defaults to *_INSTANT_APP_ID in .env', + ) + .option('--admin', 'Run the query as admin (bypasses permissions)') + .option('--as-email <email>', 'Run the query as a specific user by email') + .option('--as-guest', 'Run the query as an unauthenticated guest') + .description('Run an InstaQL query against your app.') + .action(async function (queryArg, opts) { + runCommandEffect(queryCmd(queryArg, opts)); + }); + +export const pullDef = program + .command('pull') + .argument( + '[schema|perms|all]', + 'Which configuration to push. Defaults to `all`', + ) + .option( + '-a --app <app-id>', + 'App ID to push to. Defaults to *_INSTANT_APP_ID in .env', + ) + .option( + '-p --package <react|react-native|core|admin|solid>', + 'Which package to automatically install if there is not one installed already.', + ) + .option( + '--experimental-type-preservation', + "[Experimental] Preserve manual type changes like `status: i.json<'online' | 'offline'>()` when doing `instant-cli pull schema`", + ) + .description('Pull schema and perm files from production.') + .addHelpText( + 'after', + ` +Environment Variables: + INSTANT_SCHEMA_FILE_PATH Override schema file location (default: instant.schema.ts) + INSTANT_PERMS_FILE_PATH Override perms file location (default: instant.perms.ts) +`, + ) + .action(async function (arg, inputOpts) { + return runCommandEffect(pullCommand(arg as SchemaPermsOrBoth, inputOpts)); + }); + +export const pushDef = program + .command('push') + .argument( + '[schema|perms|all]', + 'Which configuration to push. Defaults to `all`', + ) + .option( + '-a --app <app-id>', + 'App ID to push to. Defaults to *_INSTANT_APP_ID in .env', + ) + .option( + '--skip-check-types', + "Don't check types on the server when pushing schema", + ) + .option( + '--rename [renames...]', + 'List of full attribute names separated by a ":"\n Example:`push --rename posts.author:posts.creator stores.owner:stores.manager`', + ) + .option( + '-p --package <react|react-native|core|admin|solid>', + 'Which package to automatically install if there is not one installed already.', + ) + .description('Push schema and perm files to production.') + .addHelpText( + 'after', + ` +Environment Variables: + INSTANT_SCHEMA_FILE_PATH Override schema file location (default: instant.schema.ts) + INSTANT_PERMS_FILE_PATH Override perms file location (default: instant.perms.ts) +`, + ) + .action(async function (arg, inputOpts) { + runCommandEffect(pushCommand(arg, inputOpts)); + }); + +const claimDef = program + .command('claim') + .description('Transfer a tempoary app into your Instant account') + .action(async function () { + runCommandEffect(claimCommand); + }); +//// Program setup ///// + +function globalOption( + flags: string, + description?: string, + argParser?: (value: string, prev?: unknown) => unknown, +) { + const opt = new Option(flags, description); + if (argParser) { + opt.argParser(argParser); + } + // @ts-ignore + // __global does not exist on `Option`, + // but we use it in `getLocalAndGlobalOptions`, to produce + // our own custom list of local and global options. + // For more info, see the original PR: + // https://github.com/instantdb/instant/pull/505 + opt.__global = true; + return opt; +} + +function getLocalAndGlobalOptions(cmd, helper) { + const mixOfLocalAndGlobal = helper.visibleOptions(cmd); + const localOptionsFromMix = mixOfLocalAndGlobal.filter( + (option) => !option.__global, + ); + const globalOptionsFromMix = mixOfLocalAndGlobal.filter( + (option) => option.__global, + ); + const globalOptions = helper.visibleGlobalOptions(cmd); + + return [localOptionsFromMix, globalOptionsFromMix.concat(globalOptions)]; +} + +function formatHelp(cmd, helper) { + const termWidth = helper.padWidth(cmd, helper); + const helpWidth = helper.helpWidth || 80; + const itemIndentWidth = 2; + const itemSeparatorWidth = 2; // between term and description + function formatItem(term, description) { + if (description) { + const fullText = `${term.padEnd(termWidth + itemSeparatorWidth)}${description}`; + return helper.wrap( + fullText, + helpWidth - itemIndentWidth, + termWidth + itemSeparatorWidth, + ); + } + return term; + } + function formatList(textArray) { + return textArray.join('\n').replace(/^/gm, ' '.repeat(itemIndentWidth)); + } + + // Usage + let output = [`${helper.commandUsage(cmd)}`, '']; + + // Description + const commandDescription = helper.commandDescription(cmd); + if (commandDescription.length > 0) { + output = output.concat([helper.wrap(commandDescription, helpWidth, 0), '']); + } + + // Arguments + const argumentList = helper.visibleArguments(cmd).map((argument) => { + return formatItem( + helper.argumentTerm(argument), + helper.argumentDescription(argument), + ); + }); + if (argumentList.length > 0) { + output = output.concat([ + chalk.dim.bold('Arguments'), + formatList(argumentList), + '', + ]); + } + const [visibleOptions, visibleGlobalOptions] = getLocalAndGlobalOptions( + cmd, + helper, + ); + + // Options + const optionList = visibleOptions.map((option) => { + return formatItem( + helper.optionTerm(option), + helper.optionDescription(option), + ); + }); + if (optionList.length > 0) { + output = output.concat([ + chalk.dim.bold('Options'), + formatList(optionList), + '', + ]); + } + // Commands + const commandList = helper.visibleCommands(cmd).map((cmd) => { + return formatItem( + helper.subcommandTerm(cmd), + helper.subcommandDescription(cmd), + ); + }); + if (commandList.length > 0) { + output = output.concat([ + chalk.dim.bold('Commands'), + formatList(commandList), + '', + ]); + } + + if (this.showGlobalOptions) { + const globalOptionList = visibleGlobalOptions.map((option) => { + return formatItem( + helper.optionTerm(option), + helper.optionDescription(option), + ); + }); + if (globalOptionList.length > 0) { + output = output.concat([ + chalk.dim.bold('Global Options'), + formatList(globalOptionList), + '', + ]); + } + } + + return output.join('\n'); +} + +program.configureHelp({ + showGlobalOptions: true, + formatHelp, +}); + +program.parse(process.argv); diff --git a/client/packages/cli/src/new/layer.ts b/client/packages/cli/src/new/layer.ts new file mode 100644 index 0000000000..aadc7ecc52 --- /dev/null +++ b/client/packages/cli/src/new/layer.ts @@ -0,0 +1,86 @@ +import { NodeContext, NodeHttpClient } from '@effect/platform-node'; +import { Cause, Effect, Layer, ManagedRuntime } from 'effect'; +import { AuthTokenCoerceLive, AuthTokenLive } from './context/authToken.js'; +import { CurrentAppLive } from './context/currentApp.js'; +import { GlobalOptsLive } from './context/globalOpts.js'; +import { PlatformApi } from './context/platformApi.js'; +import { + PACKAGE_ALIAS_AND_FULL_NAMES, + ProjectInfoLive, +} from './context/projectInfo.js'; +import { InstantHttpAuthedLive, InstantHttpLive } from './lib/http.js'; +import { SimpleLogLayer } from './logging.js'; + +const runtime = ManagedRuntime.make(SimpleLogLayer); + +export const runCommandEffect = <A, E, R extends never>( + effect: Effect.Effect<A, E, R>, +): Promise<any> => runtime.runPromise(effect.pipe(printRedErrors)); + +export const printRedErrors = Effect.catchAllCause((cause) => { + const failure = Cause.failureOption(cause); + + // Print just the message if the error has a message attribute and no cause + if ( + failure._tag === 'Some' && + typeof failure.value === 'object' && + failure.value !== null && + 'message' in failure.value && + !('cause' in failure.value) + ) { + return Effect.logError((failure.value as { message: string }).message); + } + return Effect.logError(Cause.pretty(cause, { renderErrorCause: true })); +}); + +/** + * Note: + Avoid Duplicate Layer Creation + + Layers are memoized using reference equality. Therefore, if you have a layer that is created by calling a function like f(), you should only call that f once and re-use the resulting layer so that you are always using the same instance. + */ + +// TODO: make coerce param work for auth too + +// Base layers +const AuthTokenLayer = (allowAdminToken: boolean = true) => + Layer.provide(AuthTokenLive(allowAdminToken), NodeContext.layer); +const AuthTokenCoerceLayer = (allowAdminToken: boolean = true) => + Layer.provide(AuthTokenCoerceLive(allowAdminToken), NodeContext.layer); +const InstantHttpLayer = Layer.provide(InstantHttpLive, NodeHttpClient.layer); + +// Unauthenticated layer with InstantHttp + PlatformApi + GlobalOpts + NodeContext +export const BaseLayerLive = Layer.provideMerge( + Layer.mergeAll(InstantHttpLayer, PlatformApi.Default, GlobalOptsLive), + NodeContext.layer, +); + +// Authenticated layer extends BaseLayerLive with InstantHttpAuthed +export const AuthLayerLive = (allowAdminToken: boolean = true) => + Layer.provideMerge( + Layer.provideMerge( + InstantHttpAuthedLive, + Layer.merge(AuthTokenCoerceLayer(allowAdminToken), InstantHttpLayer), + ), + BaseLayerLive, + ); + +export const WithAppLayer = (args: { + appId?: string; + title?: string; + coerce: boolean; + packageName?: keyof typeof PACKAGE_ALIAS_AND_FULL_NAMES; + applyEnv?: boolean; +}) => + Layer.mergeAll( + CurrentAppLive({ + coerce: args.coerce, + appId: args.appId, + title: args.title, + applyEnv: args.applyEnv, + }), + ).pipe( + Layer.provideMerge(GlobalOptsLive), + Layer.provideMerge(AuthLayerLive(true)), + Layer.provideMerge(ProjectInfoLive(args.coerce, args.packageName)), + ); diff --git a/client/packages/cli/src/new/lib/createApp.ts b/client/packages/cli/src/new/lib/createApp.ts new file mode 100644 index 0000000000..5ddcab6ea5 --- /dev/null +++ b/client/packages/cli/src/new/lib/createApp.ts @@ -0,0 +1,18 @@ +import { randomUUID } from 'crypto'; +import { Effect, Schema } from 'effect'; +import { InstantHttpAuthed } from './http.js'; +import { HttpClientRequest, HttpClientResponse } from '@effect/platform'; + +export const createApp = Effect.fn(function* (title: string, orgId?: string) { + const http = yield* InstantHttpAuthed; + const id = randomUUID(); + const token = randomUUID(); + const app = { id, title, admin_token: token, org_id: orgId }; + + const res = yield* HttpClientRequest.post('/dash/apps').pipe( + HttpClientRequest.bodyJson(app), + Effect.flatMap(http.execute), + Effect.flatMap(HttpClientResponse.schemaBodyJson(Schema.Any)), + ); + return res; +}); diff --git a/client/packages/cli/src/new/lib/handleEnv.ts b/client/packages/cli/src/new/lib/handleEnv.ts new file mode 100644 index 0000000000..c6d8bc1b46 --- /dev/null +++ b/client/packages/cli/src/new/lib/handleEnv.ts @@ -0,0 +1,107 @@ +import { Effect } from 'effect'; +import { CurrentAppInfo, potentialEnvs } from '../context/currentApp.js'; +import { ProjectInfo, ProjectInfoError } from '../context/projectInfo.js'; +import { readPackage } from 'pkg-types'; +import { GlobalOpts } from '../context/globalOpts.js'; +import { FileSystem, Path } from '@effect/platform'; +import chalk from 'chalk'; +import terminalLink from 'terminal-link'; +import { getDashUrl } from './http.js'; +import { promptOk } from './ui.js'; + +export const handleEnv = Effect.fn(function* (app: CurrentAppInfo) { + const opts = yield* GlobalOpts; + const { pkgDir } = yield* ProjectInfo; + const envType = yield* detectEnvType; + const envName = potentialEnvs[envType]; + const envFile = opts.env ?? '.env'; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const hasEnvFile = yield* fs.exists(path.join(pkgDir, envFile)); + const dashOrigin = yield* getDashUrl; + if (hasEnvFile) { + return printDotEnvInfo(envType, app.appId, dashOrigin); + } + yield* Effect.log( + `\nLooks like you don't have a ${chalk.green(`\`${envFile}\``)} file yet.`, + ); + yield* Effect.log( + `If we set ${chalk.green(envName)} & ${chalk.green('INSTANT_APP_ADMIN_TOKEN')}, we can remember the app that you chose for all future commands.`, + ); + const saveExtraInfo = + envFile !== '.env' ? chalk.green(' (will create `' + envFile + '`)') : ''; + + const ok = yield* promptOk( + { + inline: true, + promptText: 'Want us to create this env file for you?' + saveExtraInfo, + modifyOutput: (a) => a, + }, + true, + ); + if (!ok) { + yield* Effect.log( + `No .env file created. You can always set ${chalk.green('`' + envName + '`')} later. \n`, + ); + return; + } + const content = + [ + [envName, app.appId], + ['INSTANT_APP_ADMIN_TOKEN', app.adminToken], + ] + .map(([k, v]) => `${k}=${v}`) + .join('\n') + '\n'; + + yield* fs.writeFileString(path.join(pkgDir, envFile), content); + + if (envFile !== '.env') { + yield* Effect.log(`Created ${chalk.green(envFile)}!`); + } else { + yield* Effect.log(`Created ${chalk.green('.env')} file!`); + } +}); + +const detectEnvType = Effect.gen(function* () { + const pkgJson = yield* Effect.tryPromise({ + try: () => readPackage(), + catch: () => + new ProjectInfoError({ message: "Couldn't read package.json" }), + }); + if (pkgJson.dependencies?.next) { + return 'next'; + } + if (pkgJson.devDependencies?.svelte) { + return 'svelte'; + } + if (pkgJson.devDependencies?.vite) { + return 'vite'; + } + if (pkgJson.dependencies?.expo) { + return 'expo'; + } + if (pkgJson.dependencies?.nuxt) { + return 'nuxt'; + } + return 'catchall'; +}).pipe(Effect.catchTag('ProjectInfoError', () => Effect.succeed('catchall'))); + +function printDotEnvInfo(envType, appId, dashOrigin: string) { + console.log(`\nPicked app ${chalk.green(appId)}!\n`); + console.log( + `To use this app automatically from now on, update your ${chalk.green('`.env`')} file:`, + ); + const picked = potentialEnvs[envType]; + const rest = { ...potentialEnvs }; + delete rest[envType]; + console.log(` ${chalk.green(picked)}=${appId}`); + const otherEnvs = Object.values(rest); + otherEnvs.sort(); + const otherEnvStr = otherEnvs.map((x) => ' ' + chalk.green(x)).join('\n'); + console.log(`Alternative names: \n${otherEnvStr} \n`); + console.log(terminalLink('Dashboard:', appDashUrl(appId, dashOrigin)) + '\n'); +} + +function appDashUrl(id, instantOrigin: string) { + return `${instantOrigin}/dash?s=main&t=home&app=${id}`; +} diff --git a/client/packages/cli/src/new/lib/http.ts b/client/packages/cli/src/new/lib/http.ts new file mode 100644 index 0000000000..a54237b9c6 --- /dev/null +++ b/client/packages/cli/src/new/lib/http.ts @@ -0,0 +1,78 @@ +import { HttpClient, HttpClientRequest } from '@effect/platform'; +import { version } from '@instantdb/version'; +import { Config, Context, Effect, Layer, Option } from 'effect'; +import { AuthToken } from '../context/authToken.js'; + +export class InstantHttp extends Context.Tag( + 'instant-cli/new/lib/http/InstantHttp', +)<InstantHttp, HttpClient.HttpClient>() {} + +export class InstantHttpAuthed extends Context.Tag( + 'instant-cli/new/lib/http/InstantHttpAuthed', +)<InstantHttpAuthed, HttpClient.HttpClient>() {} + +// Pipe on a client to set command header +export const withCommand = (command: string) => { + return (client: HttpClient.HttpClient) => + client.pipe( + HttpClient.mapRequest((r) => + r.pipe(HttpClientRequest.setHeader(`X-Instant-Command`, command)), + ), + ); +}; + +export const InstantHttpLive = Layer.effect( + InstantHttp, + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const baseUrl = yield* getBaseUrl; + return client.pipe( + HttpClient.mapRequest((r) => + r.pipe( + HttpClientRequest.prependUrl(baseUrl), + HttpClientRequest.setHeader('X-Instant-Source', 'instant-cli'), + HttpClientRequest.setHeader('X-Instant-Version', version), + ), + ), + HttpClient.filterStatusOk, // makes non 2xx http codes error + ); + }), +); + +export const InstantHttpAuthedLive = Layer.effect( + InstantHttpAuthed, + Effect.gen(function* () { + const http = yield* InstantHttp; + const { authToken } = yield* AuthToken; + return http.pipe( + HttpClient.mapRequest((r) => + r.pipe( + HttpClientRequest.setHeader('Authorization', `Bearer ${authToken}`), + ), + ), + ); + }), +); + +export const getBaseUrl = Effect.gen(function* () { + const setEnv = yield* Config.string('INSTANT_CLI_API_URI').pipe( + Config.option, + ); + const dev = yield* Config.boolean('INSTANT_CLI_DEV').pipe( + Config.withDefault(false), + ); + + return Option.match(setEnv, { + onSome: (url) => url, + onNone: () => { + return dev ? 'http://localhost:8888' : 'https://api.instantdb.com'; + }, + }); +}); + +export const getDashUrl = Effect.gen(function* () { + const dev = Option.getOrNull( + yield* Config.boolean('INSTANT_CLI_DEV').pipe(Config.option), + ); + return dev ? 'http://localhost:3000' : 'https://instantdb.com'; +}); diff --git a/client/packages/cli/src/new/lib/login.ts b/client/packages/cli/src/new/lib/login.ts new file mode 100644 index 0000000000..b9a498e20d --- /dev/null +++ b/client/packages/cli/src/new/lib/login.ts @@ -0,0 +1,51 @@ +import { Effect, Schedule, Schema } from 'effect'; +import { InstantHttp, withCommand } from './http.js'; +import { + HttpClientRequest, + HttpClientResponse, + FileSystem, +} from '@effect/platform'; +import { getAuthPaths } from '../../util/getAuthPaths.js'; + +const LoginInfo = Schema.Struct({ + secret: Schema.String, + ticket: Schema.String, +}); + +const TokenResult = Schema.Struct({ + token: Schema.String, + email: Schema.String, +}); + +export const getLoginTicketAndSecret = Effect.gen(function* () { + const http = yield* InstantHttp; + const res = yield* http + .pipe(withCommand('login')) + .post('/dash/cli/auth/register') + .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(LoginInfo))); + return res; +}); + +export const waitForAuthToken = Effect.fn(function* (secret: string) { + const http = (yield* InstantHttp).pipe(withCommand('login')); + const res = yield* HttpClientRequest.post('/dash/cli/auth/check').pipe( + HttpClientRequest.bodyJson({ + secret, + }), + Effect.flatMap(http.execute), + Effect.flatMap(HttpClientResponse.schemaBodyJson(TokenResult)), + Effect.retry({ + schedule: Schedule.fixed('1 seconds'), + times: 120, + }), + ); + return res; +}); + +export const saveConfigAuthToken = Effect.fn(function* (token: string) { + const authPaths = getAuthPaths(); + + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(authPaths.appConfigDirPath, { recursive: true }); + yield* fs.writeFileString(authPaths.authConfigFilePath, token); +}); diff --git a/client/packages/cli/src/new/lib/pullPerms.ts b/client/packages/cli/src/new/lib/pullPerms.ts new file mode 100644 index 0000000000..97d371d5b2 --- /dev/null +++ b/client/packages/cli/src/new/lib/pullPerms.ts @@ -0,0 +1,44 @@ +import { Effect, Schema } from 'effect'; +import { CurrentApp } from '../context/currentApp.js'; +import { InstantHttpAuthed, withCommand } from './http.js'; +import { HttpClientResponse } from '@effect/platform'; +import { readLocalPermsFile } from '../../index.js'; +import { getPermsPathToWrite } from '../../util/findConfigCandidates.js'; +import { promptOk } from './ui.js'; +import { UI } from '../../ui/index.js'; +import { writeTypescript } from './pullSchema.js'; +import { generatePermsTypescriptFile } from '@instantdb/platform'; +import { ProjectInfo } from '../context/projectInfo.js'; +import { Path } from '@effect/platform'; + +export const pullPerms = Effect.gen(function* () { + yield* Effect.log('Pulling perms...'); + const { appId } = yield* CurrentApp; + const http = yield* InstantHttpAuthed; + const permsResponse = yield* http + .pipe(withCommand('pull')) + .get(`/dash/apps/${appId}/perms/pull`) + .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Schema.Any))); // parse result body into "any" + + const prevPermsFile = yield* Effect.tryPromise(readLocalPermsFile); + const shortPermsPath = getPermsPathToWrite(prevPermsFile?.path); + + if (prevPermsFile) { + const shouldContinue = yield* promptOk({ + promptText: `This will overwrite your local ${shortPermsPath} file, OK to proceed?`, + modifyOutput: UI.modifiers.yPadding, + inline: true, + }); + if (!shouldContinue) return; + } + const { instantModuleName, pkgDir } = yield* ProjectInfo; + const fileContent = generatePermsTypescriptFile( + permsResponse.perms || {}, + instantModuleName, + ); + + const path = yield* Path.Path; + + yield* writeTypescript(path.join(pkgDir, shortPermsPath), fileContent); + yield* Effect.log('✅ Wrote permissions to ' + shortPermsPath); +}); diff --git a/client/packages/cli/src/new/lib/pullSchema.ts b/client/packages/cli/src/new/lib/pullSchema.ts new file mode 100644 index 0000000000..d7f48a88fb --- /dev/null +++ b/client/packages/cli/src/new/lib/pullSchema.ts @@ -0,0 +1,95 @@ +import { FileSystem, HttpClientResponse, Path } from '@effect/platform'; +import { + apiSchemaToInstantSchemaDef, + generateSchemaTypescriptFile, +} from '@instantdb/platform'; +import { Effect, Schema } from 'effect'; +import prettier from 'prettier'; +import { countEntities, readLocalSchemaFile } from '../../index.js'; +import { UI } from '../../ui/index.js'; +import { getSchemaPathToWrite } from '../../util/findConfigCandidates.js'; +import { CurrentApp } from '../context/currentApp.js'; +import { ProjectInfo } from '../context/projectInfo.js'; +import { InstantHttpAuthed, withCommand } from './http.js'; +import { promptOk } from './ui.js'; +import { ReadSchemaFileError } from './pushSchema.js'; +import { mergeSchema, MergeSchemaError } from '../../util/mergeSchema.js'; + +export const pullSchema = ({ + experimentalTypePreservation, +}: { + experimentalTypePreservation?: boolean; +}) => + Effect.gen(function* () { + yield* Effect.log('Pulling schema...'); + const { appId } = yield* CurrentApp; + const http = yield* InstantHttpAuthed; + + const schemaResponse = yield* http + .pipe(withCommand('pull')) + .get(`/dash/apps/${appId}/schema/pull`) + .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Schema.Any))); // parse result body into "any" + + if ( + !countEntities(schemaResponse.schema.refs) && + !countEntities(schemaResponse.schema.blobs) + ) { + yield* Effect.log('Schema is empty. Skipping.'); + return; + } + + const prevSchemaFile = yield* Effect.tryPromise(readLocalSchemaFile).pipe( + Effect.mapError((err) => + ReadSchemaFileError.make({ + message: `Error reading local schema file: ${err}`, + cause: err, + }), + ), + ); + const shortSchemaPath = getSchemaPathToWrite(prevSchemaFile?.path); + const path = yield* Path.Path; + const { pkgDir, instantModuleName } = yield* ProjectInfo; + const schemaPath = path.join(pkgDir, shortSchemaPath); + + if (prevSchemaFile) { + const shouldContinue = yield* promptOk({ + promptText: `This will overwrite your local ${shortSchemaPath} file, OK to proceed?`, + modifyOutput: UI.modifiers.yPadding, + inline: true, + }); + if (!shouldContinue) return; + } + + let newSchemaContent = generateSchemaTypescriptFile( + prevSchemaFile?.schema, + apiSchemaToInstantSchemaDef(schemaResponse.schema), + instantModuleName, + ); + + if (prevSchemaFile && experimentalTypePreservation) { + const fs = yield* FileSystem.FileSystem; + const oldSchemaContent = yield* fs.readFileString(prevSchemaFile.path); + newSchemaContent = yield* Effect.try(() => + mergeSchema(oldSchemaContent, newSchemaContent), + ).pipe( + Effect.mapError((e) => new MergeSchemaError({ message: e.message })), + ); + } + yield* writeTypescript(schemaPath, newSchemaContent); + yield* Effect.log('✅ Wrote schema to ' + shortSchemaPath); + }); + +export const writeTypescript = (path: string, content: string) => + Effect.gen(function* () { + const prettierConfig = yield* Effect.tryPromise(() => + prettier.resolveConfig(path), + ); + const formattedCode = yield* Effect.tryPromise(() => + prettier.format(content, { + ...prettierConfig, + parser: 'typescript', + }), + ); + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(path, formattedCode); + }); diff --git a/client/packages/cli/src/new/lib/pushPerms.ts b/client/packages/cli/src/new/lib/pushPerms.ts new file mode 100644 index 0000000000..7821a6630c --- /dev/null +++ b/client/packages/cli/src/new/lib/pushPerms.ts @@ -0,0 +1,79 @@ +import { Effect, Option, Schema } from 'effect'; +import jsonDiff from 'json-diff'; +import { readLocalPermsFile } from '../../index.js'; +import { InstantHttpAuthed, withCommand } from './http.js'; +import { + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from '@effect/platform'; +import { CurrentApp } from '../context/currentApp.js'; +import { promptOk } from './ui.js'; +import boxen from 'boxen'; +import chalk from 'chalk'; +import { parseSchemaFromJSON } from '@instantdb/core'; + +export class NoPermsFileError extends Schema.TaggedError<NoPermsFileError>( + 'NoPermsFileError', +)('NoPermsFileError', { + message: Schema.String, +}) {} + +const PullPermsResponse = Schema.Struct({ + perms: Schema.Any.pipe(Schema.optional), +}); + +export const pushPerms = Effect.gen(function* () { + yield* Effect.log('Planning perms...'); + const { appId } = yield* CurrentApp; + const http = yield* InstantHttpAuthed; + + const permsFile = yield* Effect.tryPromise(readLocalPermsFile).pipe( + Effect.flatMap(Option.fromNullable), + Effect.mapError(() => + NoPermsFileError.make({ message: 'No permissions file found' }), + ), + ); + + const prodPerms = yield* http + .pipe(withCommand('push')) + .get(`/dash/apps/${appId}/perms/pull`) + .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(PullPermsResponse))); // parse result body into "any" + + const diffedStr = jsonDiff.diffString( + prodPerms.perms || {}, + permsFile.perms || {}, + ); + + if (!diffedStr.length) { + yield* Effect.log('No perms changes detected. Skipping.'); + return; + } + + const okPush = yield* promptOk({ + promptText: 'Push these changes to your perms?', + modifyOutput: (output) => { + let both = diffedStr + '\n' + output; + return boxen(both, { + dimBorder: true, + padding: { + left: 1, + right: 1, + }, + }); + }, + }); + if (!okPush) return; + + yield* http + .pipe( + withCommand('push'), + HttpClient.mapRequestInputEffect( + HttpClientRequest.bodyJson({ code: permsFile.perms }), + ), + ) + .post(`/dash/apps/${appId}/rules`) + .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Schema.Any))); + + yield* Effect.log(chalk.green('Permissions updated!')); +}); diff --git a/client/packages/cli/src/new/lib/pushSchema.ts b/client/packages/cli/src/new/lib/pushSchema.ts new file mode 100644 index 0000000000..dc319f856f --- /dev/null +++ b/client/packages/cli/src/new/lib/pushSchema.ts @@ -0,0 +1,207 @@ +import { + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from '@effect/platform'; +import chalk from 'chalk'; +import { Data, Effect, Schema } from 'effect'; +import { + readLocalSchemaFile, + resolveRenames, + waitForIndexingJobsToFinish, +} from '../../index.js'; +import { CurrentApp } from '../context/currentApp.js'; +import { error } from '../logging.js'; +import { InstantHttpAuthed, withCommand } from './http.js'; +import { + apiSchemaToInstantSchemaDef, + buildAutoRenameSelector, + collectSystemCatalogIdentNames, + convertTxSteps, + diffSchemas, + validateSchema, +} from '@instantdb/platform'; +import { OptsFromCommand, pushDef } from '../index.js'; +import { GlobalOpts } from '../context/globalOpts.js'; +import { + groupSteps, + renderSchemaPlan, + SuperMigrationTx, +} from '../../renderSchemaPlan.js'; +import { promptOk } from './ui.js'; +import boxen from 'boxen'; + +const FetchSchemaResponse = Schema.Struct({ + schema: Schema.Struct({ + refs: Schema.Any, + blobs: Schema.Any, + }), + attrs: Schema.Array(Schema.Any).pipe(Schema.mutable), + 'app-title': Schema.String, +}).pipe(Schema.mutable); + +export class ReadSchemaFileError extends Schema.TaggedError<ReadSchemaFileError>( + 'ReadSchemaFileError', +)('ReadSchemaFileError', { + message: Schema.String, + cause: Schema.Any.pipe(Schema.optional), +}) {} + +export class SchemaDiffError extends Schema.TaggedError<SchemaDiffError>( + 'SchemaDiffError', +)('SchemaDiffError', { + message: Schema.String, +}) {} + +export class GetSchemaError extends Schema.TaggedError<GetSchemaError>( + 'GetSchemaError', +)('GetSchemaError', { + message: Schema.String, +}) {} + +export class SchemaValidationError extends Schema.TaggedError<SchemaValidationError>( + 'SchemaValidationError', +)('SchemaValidationError', { + message: Schema.String, +}) {} + +export const pushSchema = ( + rename?: OptsFromCommand<typeof pushDef>['rename'], +) => + Effect.gen(function* () { + const localSchemaFile = yield* Effect.tryPromise(readLocalSchemaFile).pipe( + Effect.mapError((e) => ReadSchemaFileError.make(e)), + ); + if (!localSchemaFile || !localSchemaFile?.schema) { + error( + `We couldn't find your ${chalk.yellow('`instant.schema.ts`')} file. Make sure it's in the root directory. (Hint: You can use an INSTANT_SCHEMA_FILE_PATH environment variable to specify it.)`, + ); + return; + } + if (localSchemaFile.schema?.constructor?.name !== 'InstantSchemaDef') { + error("We couldn't find your schema export."); + error( + 'In your ' + + chalk.green('`instant.schema.ts`') + + ' file, make sure you ' + + chalk.green('`export default schema`'), + ); + return; + } + + const http = yield* InstantHttpAuthed; + const { appId } = yield* CurrentApp; + const res = yield* http + .pipe(withCommand('push')) + .get(`/dash/apps/${appId}/schema/pull`) + .pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(FetchSchemaResponse)), + Effect.mapError((e) => GetSchemaError.make({ message: e.message })), + ); + + const currentAttrs = res['attrs']; + const currentApiSchema = res['schema']; + const oldSchema = apiSchemaToInstantSchemaDef(currentApiSchema, { + disableTypeInference: true, + }); + const systemCatalogIdentNames = + collectSystemCatalogIdentNames(currentAttrs); + + yield* Effect.tryPromise(async () => + validateSchema(localSchemaFile.schema, systemCatalogIdentNames), + ).pipe( + Effect.mapError((e) => + SchemaValidationError.make({ message: 'Invalid Schema: ' + e.message }), + ), + ); + const renames = rename && Array.isArray(rename) ? rename : []; + const globalOpts = yield* GlobalOpts; + const renameSelector = globalOpts.yes + ? buildAutoRenameSelector(renames as any) + : resolveRenames; + + const diffResult = yield* Effect.tryPromise(() => + diffSchemas( + oldSchema, + localSchemaFile.schema, + renameSelector, + systemCatalogIdentNames, + ), + ).pipe( + Effect.mapError((e) => SchemaDiffError.make({ message: e.message })), + ); + + const txSteps = convertTxSteps(diffResult, currentAttrs); + if (txSteps.length === 0) { + yield* Effect.log(chalk.bgGray('No schema changes to apply!')); + return; + } + + const groupedSteps = groupSteps(diffResult); + yield* confirmSchemaChanges(groupedSteps, currentAttrs); + + const pushRes = yield* http + .pipe( + HttpClient.mapRequestInputEffect( + HttpClientRequest.bodyJson({ + steps: txSteps, + }), + ), + ) + .post(`/dash/apps/${appId}/schema/steps/apply`) + .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Schema.Any))); + + if (pushRes?.['indexing-jobs']) { + // TODO: rewrite in effect + yield* Effect.tryPromise({ + try: () => + waitForIndexingJobsToFinish(appId, pushRes?.['indexing-jobs'] || []), + catch: (e: any) => + WaitForJobsError.make({ + message: + e?.message || 'Error waiting for schema push jobs to finish', + }), + }); + } + + yield* Effect.log(chalk.green('Schema updated!')); + }); + +export class WaitForJobsError extends Schema.TaggedError<WaitForJobsError>( + 'WaitForJobsError', +)('WaitForJobsError', { + message: Schema.String, +}) {} + +export class CancelSchemaError extends Data.TaggedError('CancelSchemaError')<{ + message: string; +}> {} + +const confirmSchemaChanges = (steps: SuperMigrationTx[], currentAttrs: any[]) => + Effect.gen(function* () { + const lines = renderSchemaPlan(steps, currentAttrs); + const globalOpts = yield* GlobalOpts; + if (globalOpts.yes) { + return; + } + const wantsToPush = yield* promptOk({ + promptText: 'Push these changes?', + yesText: 'Push', + noText: 'Cancel', + modifyOutput: (output) => { + let both = lines.join('\n') + '\n\n' + output; + return boxen(both, { + dimBorder: true, + padding: { + left: 1, + right: 1, + }, + }); + }, + }); + if (!wantsToPush) { + return yield* new CancelSchemaError({ + message: 'Schema Migration Cancelled', + }); + } + }); diff --git a/client/packages/cli/src/new/lib/ui.ts b/client/packages/cli/src/new/lib/ui.ts new file mode 100644 index 0000000000..7add4c7aea --- /dev/null +++ b/client/packages/cli/src/new/lib/ui.ts @@ -0,0 +1,36 @@ +import { Data, Effect } from 'effect'; +import { GlobalOpts } from '../context/globalOpts.js'; +import { Prompt, renderUnwrap } from '../../ui/lib.js'; +import { UI } from '../../ui/index.js'; + +export class UIError extends Data.TaggedError('UIError')<{ + message: string; + cause?: unknown; +}> {} + +export const promptOk = Effect.fn('promptOk')(function* ( + props: UI.ConfirmationProps, + defaultValue: boolean = true, +) { + const opts = yield* GlobalOpts; + if (opts.yes) { + return defaultValue; + } + + const ok = yield* Effect.tryPromise(() => + renderUnwrap( + new UI.Confirmation({ + ...props, + defaultValue, + }), + ), + ).pipe(Effect.orElseSucceed(() => defaultValue)); + + return ok; +}); + +export const runUIEffect = <P>(prompt: Prompt<P>) => + Effect.tryPromise({ + try: () => renderUnwrap(prompt), + catch: (error) => new UIError({ message: 'UI Error', cause: error }), + }); diff --git a/client/packages/cli/src/new/logging.ts b/client/packages/cli/src/new/logging.ts new file mode 100644 index 0000000000..2089dd4ba5 --- /dev/null +++ b/client/packages/cli/src/new/logging.ts @@ -0,0 +1,32 @@ +import chalk from 'chalk'; +import { HashMap, Logger, Match, Option } from 'effect'; + +export function warn(firstArg, ...rest) { + console.warn(chalk.yellow('[warning]') + ' ' + firstArg, ...rest); +} + +export function error(firstArg, ...rest) { + console.error(chalk.red('[error]') + ' ' + firstArg, ...rest); +} + +const simpleLogger = Logger.make(({ logLevel, message, annotations }) => { + const isSilent = HashMap.get(annotations, 'silent').pipe(Option.getOrNull); + if (isSilent) return; + const formattedMessage = Array.isArray(message) ? message.join(' ') : message; + Match.value(logLevel).pipe( + Match.tag('Info', () => console.log(formattedMessage)), + Match.tag('Warning', () => console.warn(formattedMessage)), + Match.tag('Error', () => + console.error(chalk.red('[error]') + ' ' + formattedMessage), + ), + Match.tag('Debug', () => console.debug(formattedMessage)), + Match.tag('Fatal', () => + console.error(chalk.red('[error]') + ' ' + formattedMessage), + ), + ); +}); + +export const SimpleLogLayer = Logger.replace( + Logger.defaultLogger, + simpleLogger, +); diff --git a/client/packages/cli/src/new/program.ts b/client/packages/cli/src/new/program.ts new file mode 100644 index 0000000000..c88b800e84 --- /dev/null +++ b/client/packages/cli/src/new/program.ts @@ -0,0 +1,3 @@ +import { Command } from '@commander-js/extra-typings'; + +export const program = new Command(); diff --git a/client/packages/cli/src/renderSchemaPlan.ts b/client/packages/cli/src/renderSchemaPlan.ts index ff28408564..7baf817555 100644 --- a/client/packages/cli/src/renderSchemaPlan.ts +++ b/client/packages/cli/src/renderSchemaPlan.ts @@ -98,7 +98,7 @@ type AddOrDeleteAttr = | MigrationTxSpecific<'add-attr'> | MigrationTxSpecific<'delete-attr'>; -type SuperMigrationTx = +export type SuperMigrationTx = | MigrationTx | { type: 'create-namespace'; namespace: string; innerSteps: MigrationTx[] } | { type: 'delete-namespace'; namespace: string; innerSteps: MigrationTx[] }; diff --git a/client/packages/cli/src/ui/index.ts b/client/packages/cli/src/ui/index.ts index 5209609b66..2b93964780 100644 --- a/client/packages/cli/src/ui/index.ts +++ b/client/packages/cli/src/ui/index.ts @@ -864,7 +864,7 @@ ${inputDisplay}`; } } - interface AppSelectorApi { + export interface AppSelectorApi { getDash: () => { apps: App[]; orgs: Org[] }; createEphemeralApp: (title: string) => Promise<{ appId: string; diff --git a/client/packages/cli/src/util/getAuthPaths.ts b/client/packages/cli/src/util/getAuthPaths.ts index 0af3ae571d..5a8e8a3c25 100644 --- a/client/packages/cli/src/util/getAuthPaths.ts +++ b/client/packages/cli/src/util/getAuthPaths.ts @@ -2,6 +2,7 @@ import envPaths from 'env-paths'; import { join } from 'path'; const dev = Boolean(process.env.INSTANT_CLI_DEV); + export function getAuthPaths() { const key = `instantdb-${dev ? 'dev' : 'prod'}`; const { config: appConfigDirPath } = envPaths(key); diff --git a/client/packages/cli/src/util/mergeSchema.js b/client/packages/cli/src/util/mergeSchema.ts similarity index 96% rename from client/packages/cli/src/util/mergeSchema.js rename to client/packages/cli/src/util/mergeSchema.ts index afbf3346c4..de5df89957 100644 --- a/client/packages/cli/src/util/mergeSchema.js +++ b/client/packages/cli/src/util/mergeSchema.ts @@ -1,6 +1,12 @@ import * as acorn from 'acorn'; import tsPlugin from 'acorn-typescript'; +import { Data } from 'effect'; +export class MergeSchemaError extends Data.TaggedError('MergeSchemaError')<{ + message: string; +}> {} + +// @ts-ignore const node = acorn.Parser.extend(tsPlugin({ dts: false })); // --- Import Handling Helpers --- @@ -102,7 +108,7 @@ function getPropName(prop) { return null; } -function analyzeChain(node) { +function analyzeChain(node): any { let curr = node; let typeParams = null; let baseCall = null; @@ -192,7 +198,7 @@ function findSchemaObject(ast) { return schemaObj; } -export function mergeSchema(oldFile, newFile) { +export function mergeSchema(oldFile, newFile): string { const oldParsed = node.parse(oldFile, { sourceType: 'module', ecmaVersion: 'latest', @@ -225,7 +231,7 @@ export function mergeSchema(oldFile, newFile) { const neededIdentifiers = new Set(); // 3. Apply to new file & Collect needed identifiers - const edits = []; + const edits: any[] = []; const newSchemaObj = findSchemaObject(newParsed); if (newSchemaObj) { @@ -290,7 +296,7 @@ export function mergeSchema(oldFile, newFile) { } } - const importBlocks = []; + const importBlocks: string[] = []; for (const [source, info] of importsToAdd) { // Check if source exists in new file to merge? @@ -299,7 +305,7 @@ export function mergeSchema(oldFile, newFile) { // If we have named imports if (info.named.size > 0) { - const namedImports = Array.from(info.named.values()); + const namedImports: any[] = Array.from(info.named.values()); const allTypes = namedImports.every((x) => x.isType); if (allTypes) { diff --git a/client/packages/cli/tsconfig.json b/client/packages/cli/tsconfig.json index 06246f01f3..4d49715cda 100644 --- a/client/packages/cli/tsconfig.json +++ b/client/packages/cli/tsconfig.json @@ -5,6 +5,11 @@ "rootDir": "src", "sourceMap": true, "skipLibCheck": true, // `unconfig` currently imports a broken type from `@antfu/utils`, + "plugins": [ + { + "name": "@effect/language-service" + } + ], "strictNullChecks": true // remove once fixed }, diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index ad155eb331..1752a08930 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -69,6 +69,18 @@ importers: packages/cli: dependencies: + '@commander-js/extra-typings': + specifier: ^14.0.0 + version: 14.0.0(commander@12.1.0) + '@effect/cli': + specifier: ^0.73.0 + version: 0.73.0(@effect/platform@0.94.1(effect@3.19.14))(@effect/printer-ansi@0.47.0(@effect/typeclass@0.38.0(effect@3.19.14))(effect@3.19.14))(@effect/printer@0.47.0(@effect/typeclass@0.38.0(effect@3.19.14))(effect@3.19.14))(effect@3.19.14) + '@effect/platform': + specifier: ^0.94.1 + version: 0.94.1(effect@3.19.14) + '@effect/platform-node': + specifier: ^0.104.0 + version: 0.104.0(@effect/cluster@0.56.1(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14) '@instantdb/core': specifier: workspace:* version: link:../core @@ -102,6 +114,9 @@ importers: dotenv-flow: specifier: ^4.1.0 version: 4.1.0 + effect: + specifier: ^3.19.14 + version: 3.19.14 env-paths: specifier: ^3.0.0 version: 3.0.0 @@ -123,6 +138,12 @@ importers: package-directory: specifier: ^8.1.0 version: 8.1.0 + package-manager-detector: + specifier: ^1.6.0 + version: 1.6.0 + pkg-types: + specifier: ^2.3.0 + version: 2.3.0 prettier: specifier: ^3.3.3 version: 3.4.2 @@ -148,6 +169,9 @@ importers: '@babel/preset-env': specifier: ^7.16.11 version: 7.26.7(@babel/core@7.26.7) + '@effect/language-service': + specifier: ^0.64.1 + version: 0.64.1 '@types/json-diff': specifier: ^1.0.3 version: 1.0.3 @@ -155,7 +179,7 @@ importers: specifier: ^4.1.9 version: 4.1.9 '@types/node': - specifier: ^22.6.1 + specifier: ^22.13.0 version: 22.13.0 npm-run-all: specifier: ^4.1.5 @@ -2831,6 +2855,11 @@ packages: resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} + '@commander-js/extra-typings@14.0.0': + resolution: {integrity: sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg==} + peerDependencies: + commander: ~14.0.0 + '@corex/deepmerge@4.0.43': resolution: {integrity: sha512-N8uEMrMPL0cu/bdboEWpQYb/0i2K5Qn8eCsxzOmxSggJbbQte7ljMRoXm917AbntqTGOzdTu+vP3KOOzoC70HQ==} @@ -2921,6 +2950,101 @@ packages: search-insights: optional: true + '@effect/cli@0.73.0': + resolution: {integrity: sha512-KkRtFjfyG52kQ6Z3ZEjwytfKZQACsLzn/RI2jKKxMJDebY9BQganOiE3VGCT4slU3Zisur6SP//ghM0vCLQs4g==} + peerDependencies: + '@effect/platform': ^0.94.0 + '@effect/printer': ^0.47.0 + '@effect/printer-ansi': ^0.47.0 + effect: ^3.19.13 + + '@effect/cluster@0.56.1': + resolution: {integrity: sha512-gnrsH6kfrUjn+82j/bw1IR4yFqJqV8tc7xZvrbJPRgzANycc6K1hu3LMg548uYbUkTzD8YYyqrSatMO1mkQpzw==} + peerDependencies: + '@effect/platform': ^0.94.1 + '@effect/rpc': ^0.73.0 + '@effect/sql': ^0.49.0 + '@effect/workflow': ^0.16.0 + effect: ^3.19.14 + + '@effect/experimental@0.58.0': + resolution: {integrity: sha512-IEP9sapjF6rFy5TkoqDPc86st/fnqUfjT7Xa3pWJrFGr1hzaMXHo+mWsYOZS9LAOVKnpHuVziDK97EP5qsCHVA==} + peerDependencies: + '@effect/platform': ^0.94.0 + effect: ^3.19.13 + ioredis: ^5 + lmdb: ^3 + peerDependenciesMeta: + ioredis: + optional: true + lmdb: + optional: true + + '@effect/language-service@0.64.1': + resolution: {integrity: sha512-1S7Xr2t9mygR6QyiIdCQUvPwSvcf5EBpnWmKa/8rC4P0btFfw/RiWRI8bvFI2AW+U4KStMUHYxPu4QEsk4y8bg==} + hasBin: true + + '@effect/platform-node-shared@0.57.0': + resolution: {integrity: sha512-QXuvmLNlABCQLcTl+lN1YPhKosR6KqArPYjC2reU0fb5lroCo3YRb/aGpXIgLthHzQL8cLU5XMGA3Cu5hKY2Tw==} + peerDependencies: + '@effect/cluster': ^0.56.0 + '@effect/platform': ^0.94.0 + '@effect/rpc': ^0.73.0 + '@effect/sql': ^0.49.0 + effect: ^3.19.13 + + '@effect/platform-node@0.104.0': + resolution: {integrity: sha512-2ZkUDDTxLD95ARdYIKBx4tdIIgqA3cwb3jlnVVBxmHUf0Pg5N2HdMuD0Q+CXQ7Q94FDwnLW3ZvaSfxDh6FvrNw==} + peerDependencies: + '@effect/cluster': ^0.56.0 + '@effect/platform': ^0.94.0 + '@effect/rpc': ^0.73.0 + '@effect/sql': ^0.49.0 + effect: ^3.19.13 + + '@effect/platform@0.94.1': + resolution: {integrity: sha512-SlL8OMTogHmMNnFLnPAHHo3ua1yrB1LNQOVQMiZsqYu9g3216xjr0gn5WoDgCxUyOdZcseegMjWJ7dhm/2vnfg==} + peerDependencies: + effect: ^3.19.14 + + '@effect/printer-ansi@0.47.0': + resolution: {integrity: sha512-tDEQ9XJpXDNYoWMQJHFRMxKGmEOu6z32x3Kb8YLOV5nkauEKnKmWNs7NBp8iio/pqoJbaSwqDwUg9jXVquxfWQ==} + peerDependencies: + '@effect/typeclass': ^0.38.0 + effect: ^3.19.0 + + '@effect/printer@0.47.0': + resolution: {integrity: sha512-VgR8e+YWWhMEAh9qFOjwiZ3OXluAbcVLIOtvp2S5di1nSrPOZxj78g8LE77JSvyfp5y5bS2gmFW+G7xD5uU+2Q==} + peerDependencies: + '@effect/typeclass': ^0.38.0 + effect: ^3.19.0 + + '@effect/rpc@0.73.0': + resolution: {integrity: sha512-iMPf6tTriz8sK0l5x4koFId8Hz5nFptHYg8WqyjHGIIVLTpZxuiSqhmXZG7FnAs5N2n6uCEws4wWGcIgXNUrFg==} + peerDependencies: + '@effect/platform': ^0.94.0 + effect: ^3.19.13 + + '@effect/sql@0.49.0': + resolution: {integrity: sha512-9UEKR+z+MrI/qMAmSvb/RiD9KlgIazjZUCDSpwNgm0lEK9/Q6ExEyfziiYFVCPiptp52cBw8uBHRic8hHnwqXA==} + peerDependencies: + '@effect/experimental': ^0.58.0 + '@effect/platform': ^0.94.0 + effect: ^3.19.13 + + '@effect/typeclass@0.38.0': + resolution: {integrity: sha512-lMUcJTRtG8KXhXoczapZDxbLK5os7M6rn0zkvOgncJW++A0UyelZfMVMKdT5R+fgpZcsAU/1diaqw3uqLJwGxA==} + peerDependencies: + effect: ^3.19.0 + + '@effect/workflow@0.16.0': + resolution: {integrity: sha512-MiAdlxx3TixkgHdbw+Yf1Z3tHAAE0rOQga12kIydJqj05Fnod+W/I+kQGRMY/XWRg+QUsVxhmh1qTr7Ype6lrw==} + peerDependencies: + '@effect/experimental': ^0.58.0 + '@effect/platform': ^0.94.0 + '@effect/rpc': ^0.73.0 + effect: ^3.19.13 + '@emnapi/core@1.4.3': resolution: {integrity: sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==} @@ -4708,6 +4832,36 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + '@mux/mux-data-google-ima@0.2.8': resolution: {integrity: sha512-0ZEkHdcZ6bS8QtcjFcoJeZxJTpX7qRIledf4q1trMWPznugvtajCjCM2kieK/pzkZj1JM6liDRFs1PJSfVUs2A==} @@ -9878,6 +10032,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@3.19.14: + resolution: {integrity: sha512-3vwdq0zlvQOxXzXNKRIPKTqZNMyGCdaFUBfMPqpsyzZDre67kgC1EEHDV4EoQTovJ4w5fmJW756f86kkuz7WFA==} + electron-to-chromium@1.5.150: resolution: {integrity: sha512-rOOkP2ZUMx1yL4fCxXQKDHQ8ZXwisb2OycOQVKHgvB3ZI4CvehOd4y2tfnnLDieJ3Zs1RL1Dlp3cMkyIn7nnXA==} @@ -10477,6 +10634,9 @@ packages: exsolve@1.0.5: resolution: {integrity: sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg==} + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + extend-shallow@2.0.1: resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} engines: {node: '>=0.10.0'} @@ -10639,6 +10799,9 @@ packages: resolution: {integrity: sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==} engines: {node: '>=6'} + find-my-way-ts@0.1.6: + resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} + find-up-simple@1.0.0: resolution: {integrity: sha512-q7Us7kcjj2VMePAa02hDAF6d+MzsdsAWEwYyOpwUtlerRBkOEPBCRZrAV4XfcSN8fHAgaD0hP7miwoay6DCprw==} engines: {node: '>=18'} @@ -11293,6 +11456,10 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ini@4.1.3: + resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + inline-style-parser@0.2.4: resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} @@ -12023,6 +12190,9 @@ packages: kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + kubernetes-types@1.30.0: + resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} + kuler@2.0.0: resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} @@ -13052,6 +13222,16 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.8: + resolution: {integrity: sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA==} + + multipasta@0.2.7: + resolution: {integrity: sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==} + mutative@1.1.0: resolution: {integrity: sha512-2PJADREjOusk3iJkD3rXV2YjAxTuaLxdfqtqTEt6vcY07LtEBR1seHuBHXWEIuscqRDGvbauYPs+A4Rj/KTczQ==} engines: {node: '>=14.0'} @@ -13249,6 +13429,10 @@ packages: resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} engines: {node: '>= 6.13.0'} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-gyp-build@4.8.4: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true @@ -13608,6 +13792,9 @@ packages: package-manager-detector@1.3.0: resolution: {integrity: sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@0.2.9: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} @@ -13833,6 +14020,9 @@ packages: pkg-types@2.1.0: resolution: {integrity: sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A==} + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + player.style@0.2.0: resolution: {integrity: sha512-Ngoaz49TClptMr8HDA2IFmjT3Iq6R27QEUH/C+On33L59RSF3dCLefBYB1Au2RDZQJ6oVFpc1sXaPVpp7fEzzA==} @@ -16110,6 +16300,10 @@ packages: resolution: {integrity: sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==} engines: {node: '>=18.17'} + undici@7.18.2: + resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} + engines: {node: '>=20.18.1'} + unenv@2.0.0-rc.15: resolution: {integrity: sha512-J/rEIZU8w6FOfLNz/hNKsnY+fFHWnu9MH4yRbSZF3xbbGHovcetXPs7sD+9p8L6CeNC//I9bhRYAOsBt2u7/OA==} @@ -19373,6 +19567,10 @@ snapshots: '@colors/colors@1.6.0': {} + '@commander-js/extra-typings@14.0.0(commander@12.1.0)': + dependencies: + commander: 12.1.0 + '@corex/deepmerge@4.0.43': {} '@cspotcode/source-map-support@0.8.1': @@ -19458,6 +19656,106 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' + '@effect/cli@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(@effect/printer-ansi@0.47.0(@effect/typeclass@0.38.0(effect@3.19.14))(effect@3.19.14))(@effect/printer@0.47.0(@effect/typeclass@0.38.0(effect@3.19.14))(effect@3.19.14))(effect@3.19.14)': + dependencies: + '@effect/platform': 0.94.1(effect@3.19.14) + '@effect/printer': 0.47.0(@effect/typeclass@0.38.0(effect@3.19.14))(effect@3.19.14) + '@effect/printer-ansi': 0.47.0(@effect/typeclass@0.38.0(effect@3.19.14))(effect@3.19.14) + effect: 3.19.14 + ini: 4.1.3 + toml: 3.0.0 + yaml: 2.7.0 + + '@effect/cluster@0.56.1(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(effect@3.19.14)': + dependencies: + '@effect/platform': 0.94.1(effect@3.19.14) + '@effect/rpc': 0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14) + '@effect/sql': 0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14) + '@effect/workflow': 0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14) + effect: 3.19.14 + kubernetes-types: 1.30.0 + + '@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1)': + dependencies: + '@effect/platform': 0.94.1(effect@3.19.14) + effect: 3.19.14 + uuid: 11.1.0 + optionalDependencies: + ioredis: 5.6.1 + + '@effect/language-service@0.64.1': {} + + '@effect/platform-node-shared@0.57.0(@effect/cluster@0.56.1(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14)': + dependencies: + '@effect/cluster': 0.56.1(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(effect@3.19.14) + '@effect/platform': 0.94.1(effect@3.19.14) + '@effect/rpc': 0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14) + '@effect/sql': 0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14) + '@parcel/watcher': 2.5.1 + effect: 3.19.14 + multipasta: 0.2.7 + ws: 8.18.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/platform-node@0.104.0(@effect/cluster@0.56.1(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14)': + dependencies: + '@effect/cluster': 0.56.1(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(effect@3.19.14) + '@effect/platform': 0.94.1(effect@3.19.14) + '@effect/platform-node-shared': 0.57.0(@effect/cluster@0.56.1(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14) + '@effect/rpc': 0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14) + '@effect/sql': 0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14) + effect: 3.19.14 + mime: 3.0.0 + undici: 7.18.2 + ws: 8.18.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/platform@0.94.1(effect@3.19.14)': + dependencies: + effect: 3.19.14 + find-my-way-ts: 0.1.6 + msgpackr: 1.11.8 + multipasta: 0.2.7 + + '@effect/printer-ansi@0.47.0(@effect/typeclass@0.38.0(effect@3.19.14))(effect@3.19.14)': + dependencies: + '@effect/printer': 0.47.0(@effect/typeclass@0.38.0(effect@3.19.14))(effect@3.19.14) + '@effect/typeclass': 0.38.0(effect@3.19.14) + effect: 3.19.14 + + '@effect/printer@0.47.0(@effect/typeclass@0.38.0(effect@3.19.14))(effect@3.19.14)': + dependencies: + '@effect/typeclass': 0.38.0(effect@3.19.14) + effect: 3.19.14 + + '@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)': + dependencies: + '@effect/platform': 0.94.1(effect@3.19.14) + effect: 3.19.14 + msgpackr: 1.11.8 + + '@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)': + dependencies: + '@effect/experimental': 0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1) + '@effect/platform': 0.94.1(effect@3.19.14) + effect: 3.19.14 + uuid: 11.1.0 + + '@effect/typeclass@0.38.0(effect@3.19.14)': + dependencies: + effect: 3.19.14 + + '@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14)': + dependencies: + '@effect/experimental': 0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1) + '@effect/platform': 0.94.1(effect@3.19.14) + '@effect/rpc': 0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14) + effect: 3.19.14 + '@emnapi/core@1.4.3': dependencies: '@emnapi/wasi-threads': 1.0.2 @@ -21448,6 +21746,24 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + '@mux/mux-data-google-ima@0.2.8': dependencies: mux-embed: 5.9.0 @@ -27398,6 +27714,11 @@ snapshots: ee-first@1.1.1: {} + effect@3.19.14: + dependencies: + '@standard-schema/spec': 1.0.0 + fast-check: 3.23.2 + electron-to-chromium@1.5.150: {} electron-to-chromium@1.5.90: {} @@ -28292,6 +28613,8 @@ snapshots: exsolve@1.0.5: {} + exsolve@1.0.8: {} + extend-shallow@2.0.1: dependencies: is-extendable: 0.1.1 @@ -28475,6 +28798,8 @@ snapshots: make-dir: 2.1.0 pkg-dir: 3.0.0 + find-my-way-ts@0.1.6: {} + find-up-simple@1.0.0: {} find-up@3.0.0: @@ -29246,6 +29571,8 @@ snapshots: ini@4.1.1: {} + ini@4.1.3: {} + inline-style-parser@0.2.4: {} inline-style-prefixer@6.0.4: @@ -30231,6 +30558,8 @@ snapshots: kolorist@1.8.0: {} + kubernetes-types@1.30.0: {} + kuler@2.0.0: {} lambda-local@2.2.0: @@ -31628,6 +31957,24 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.8: + optionalDependencies: + msgpackr-extract: 3.0.3 + + multipasta@0.2.7: {} + mutative@1.1.0: {} mute-stream@1.0.0: {} @@ -31966,6 +32313,11 @@ snapshots: node-forge@1.3.1: {} + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-gyp-build@4.8.4: {} node-int64@0.4.0: {} @@ -32449,6 +32801,8 @@ snapshots: package-manager-detector@1.3.0: {} + package-manager-detector@1.6.0: {} + pako@0.2.9: {} parent-module@1.0.1: @@ -32669,6 +33023,12 @@ snapshots: exsolve: 1.0.5 pathe: 2.0.3 + pkg-types@2.3.0: + dependencies: + confbox: 0.2.2 + exsolve: 1.0.8 + pathe: 2.0.3 + player.style@0.2.0(react@18.3.1): dependencies: media-chrome: 4.13.1(react@18.3.1) @@ -35411,6 +35771,8 @@ snapshots: undici@6.21.1: {} + undici@7.18.2: {} + unenv@2.0.0-rc.15: dependencies: defu: 6.1.4 From 8923fb871414673b376f1bf14db1b21aec864503 Mon Sep 17 00:00:00 2001 From: Drew Harris <drew@drewh.net> Date: Thu, 12 Mar 2026 10:57:58 -0700 Subject: [PATCH 02/15] fix p2 issues --- client/packages/cli/src/new/commands/info.ts | 6 ++- client/packages/cli/src/new/commands/init.ts | 9 +--- .../cli/src/new/commands/initWithoutFiles.ts | 8 ++- .../packages/cli/src/new/context/authToken.ts | 21 ++++++-- .../cli/src/new/context/currentApp.ts | 9 +++- client/packages/cli/src/new/layer.ts | 50 +++++++++++++++---- client/packages/cli/src/new/lib/handleEnv.ts | 3 ++ client/packages/cli/src/new/lib/http.ts | 3 ++ client/packages/cli/src/new/lib/pushPerms.ts | 1 - 9 files changed, 80 insertions(+), 30 deletions(-) diff --git a/client/packages/cli/src/new/commands/info.ts b/client/packages/cli/src/new/commands/info.ts index a465f6951f..642602e5c5 100644 --- a/client/packages/cli/src/new/commands/info.ts +++ b/client/packages/cli/src/new/commands/info.ts @@ -3,7 +3,6 @@ import { Effect, Layer, pipe, Schema, Option } from 'effect'; import { AuthLayerLive } from '../layer.js'; import { InstantHttpAuthed } from '../lib/http.js'; import { version } from '@instantdb/version'; -import { AuthToken } from '../context/authToken.js'; const DashMeResponse = Schema.Struct({ user: Schema.Struct({ @@ -35,6 +34,9 @@ export const infoCommand = () => } }).pipe( Effect.provide( - AuthLayerLive(false).pipe(Layer.catchAll((e) => Layer.empty)), + AuthLayerLive({ + coerce: false, + allowAdminToken: false, + }).pipe(Layer.catchAll((e) => Layer.empty)), ), ); diff --git a/client/packages/cli/src/new/commands/init.ts b/client/packages/cli/src/new/commands/init.ts index 2e7da0df8f..6decc94f58 100644 --- a/client/packages/cli/src/new/commands/init.ts +++ b/client/packages/cli/src/new/commands/init.ts @@ -1,7 +1,5 @@ import { Effect, Option } from 'effect'; import { OptsFromCommand, initDef } from '../index.js'; -import { ProjectInfo } from '../context/projectInfo.js'; -import { CurrentApp } from '../context/currentApp.js'; import { WithAppLayer } from '../layer.js'; import { readLocalPermsFile, readLocalSchemaFile } from '../../index.js'; import { pullSchema } from '../lib/pullSchema.js'; @@ -12,9 +10,6 @@ import { pushPerms } from '../lib/pushPerms.js'; export const initCommand = (options: OptsFromCommand<typeof initDef>) => Effect.gen(function* () { - const _info = yield* ProjectInfo; - const _app = yield* CurrentApp; - yield* Effect.matchEffect( Effect.tryPromise(readLocalSchemaFile).pipe( // Throws NoSuchElementException if no file found @@ -29,7 +24,7 @@ export const initCommand = (options: OptsFromCommand<typeof initDef>) => promptText: 'Found local schema. Push it to the new app?', inline: true, }, - false, + true, ); if (doSchemaPush) { yield* pushSchema(); @@ -52,7 +47,7 @@ export const initCommand = (options: OptsFromCommand<typeof initDef>) => promptText: 'Found local perms. Push it to the new app?', inline: true, }, - false, + true, ); if (doPermsPush) { yield* pushPerms; diff --git a/client/packages/cli/src/new/commands/initWithoutFiles.ts b/client/packages/cli/src/new/commands/initWithoutFiles.ts index a183725838..fcc411ab19 100644 --- a/client/packages/cli/src/new/commands/initWithoutFiles.ts +++ b/client/packages/cli/src/new/commands/initWithoutFiles.ts @@ -3,7 +3,6 @@ import { PlatformApi } from '../context/platformApi.js'; import { BadArgsError } from '../errors.js'; import { OptsFromCommand, initWithoutFilesDef } from '../index.js'; import { createApp } from '../lib/createApp.js'; -import { AuthTokenLive } from '../context/authToken.js'; import { AuthLayerLive } from '../layer.js'; import chalk from 'chalk'; @@ -30,7 +29,12 @@ export const initWithoutFilesCommand = Effect.fn(function* ( if (!opts.temp) { const app = yield* createApp(opts.title, opts.orgId).pipe( - Effect.provide(AuthLayerLive(false)), + Effect.provide( + AuthLayerLive({ + allowAdminToken: false, + coerce: false, + }), + ), ); console.error(`${chalk.green('Successfully created new app!')}\n`); yield* Effect.log( diff --git a/client/packages/cli/src/new/context/authToken.ts b/client/packages/cli/src/new/context/authToken.ts index 0c2843353a..1101e43dec 100644 --- a/client/packages/cli/src/new/context/authToken.ts +++ b/client/packages/cli/src/new/context/authToken.ts @@ -70,14 +70,25 @@ export const authTokenGetEffect = (allowAdminToken: boolean = true) => return yield* NotAuthedError.make({ message: 'You are not logged in' }); }); -export const AuthTokenLive = (allowAdminToken: boolean = true) => - Layer.effect(AuthToken, authTokenGetEffect(allowAdminToken)); - -export const AuthTokenCoerceLive = (allowAdminToken: boolean = true) => +export const AuthTokenLive = ({ + coerce, + allowAdminToken = true, +}: { + coerce: boolean; + allowAdminToken: boolean; +}) => Layer.effect( AuthToken, authTokenGetEffect(allowAdminToken).pipe( - Effect.catchTag('NotAuthedError', () => loginCommand({})), + Effect.catchTag('NotAuthedError', (e) => + Effect.gen(function* () { + if (coerce) { + return yield* loginCommand({}); + } else { + return yield* e; + } + }), + ), ), ); diff --git a/client/packages/cli/src/new/context/currentApp.ts b/client/packages/cli/src/new/context/currentApp.ts index 281a8558ab..180c064d0f 100644 --- a/client/packages/cli/src/new/context/currentApp.ts +++ b/client/packages/cli/src/new/context/currentApp.ts @@ -82,8 +82,13 @@ export const CurrentAppLive = (args: { const config = yield* Effect.tryPromise(() => readInstantConfigFile(), ); + if (!config) { + return yield* new BadArgsError({ + message: `App ID provided (${args.appId}) is not valid UUID`, + }); + } const nameMatch = config?.apps?.[args.appId]; - if (!nameMatch.id) { + if (!nameMatch?.id) { return yield* BadArgsError.make({ message: 'App ID not found in app inside instant.config.ts', }); @@ -265,5 +270,5 @@ const getAdminToken = Effect.gen(function* () { const trackAppImport = (appId: string) => Effect.gen(function* () { const http = (yield* InstantHttpAuthed).pipe(withCommand('init')); - yield* http.post(`/dash/apps/${appId}/import`); + yield* http.post(`/dash/apps/${appId}/track-import`); }); diff --git a/client/packages/cli/src/new/layer.ts b/client/packages/cli/src/new/layer.ts index aadc7ecc52..a5259f37a0 100644 --- a/client/packages/cli/src/new/layer.ts +++ b/client/packages/cli/src/new/layer.ts @@ -1,6 +1,6 @@ import { NodeContext, NodeHttpClient } from '@effect/platform-node'; import { Cause, Effect, Layer, ManagedRuntime } from 'effect'; -import { AuthTokenCoerceLive, AuthTokenLive } from './context/authToken.js'; +import { AuthTokenLive } from './context/authToken.js'; import { CurrentAppLive } from './context/currentApp.js'; import { GlobalOptsLive } from './context/globalOpts.js'; import { PlatformApi } from './context/platformApi.js'; @@ -15,7 +15,7 @@ const runtime = ManagedRuntime.make(SimpleLogLayer); export const runCommandEffect = <A, E, R extends never>( effect: Effect.Effect<A, E, R>, -): Promise<any> => runtime.runPromise(effect.pipe(printRedErrors)); +): Promise<any> => runtime.runPromise(effect.pipe(printRedErrors) as any); export const printRedErrors = Effect.catchAllCause((cause) => { const failure = Cause.failureOption(cause); @@ -28,9 +28,17 @@ export const printRedErrors = Effect.catchAllCause((cause) => { 'message' in failure.value && !('cause' in failure.value) ) { - return Effect.logError((failure.value as { message: string }).message); + return Effect.logError((failure.value as { message: string }).message).pipe( + Effect.tap(() => { + process.exit(1); + }), + ); } - return Effect.logError(Cause.pretty(cause, { renderErrorCause: true })); + return Effect.logError(Cause.pretty(cause, { renderErrorCause: true })).pipe( + Effect.tap(() => { + process.exit(1); + }), + ); }); /** @@ -43,10 +51,15 @@ export const printRedErrors = Effect.catchAllCause((cause) => { // TODO: make coerce param work for auth too // Base layers -const AuthTokenLayer = (allowAdminToken: boolean = true) => - Layer.provide(AuthTokenLive(allowAdminToken), NodeContext.layer); -const AuthTokenCoerceLayer = (allowAdminToken: boolean = true) => - Layer.provide(AuthTokenCoerceLive(allowAdminToken), NodeContext.layer); +const AuthTokenLayer = ({ + allowAdminToken = true, + coerce = false, +}: { + allowAdminToken: boolean; + coerce: boolean; +}) => + Layer.provide(AuthTokenLive({ allowAdminToken, coerce }), NodeContext.layer); + const InstantHttpLayer = Layer.provide(InstantHttpLive, NodeHttpClient.layer); // Unauthenticated layer with InstantHttp + PlatformApi + GlobalOpts + NodeContext @@ -56,11 +69,20 @@ export const BaseLayerLive = Layer.provideMerge( ); // Authenticated layer extends BaseLayerLive with InstantHttpAuthed -export const AuthLayerLive = (allowAdminToken: boolean = true) => +export const AuthLayerLive = ({ + allowAdminToken = true, + coerce = false, +}: { + allowAdminToken: boolean; + coerce: boolean; +}) => Layer.provideMerge( Layer.provideMerge( InstantHttpAuthedLive, - Layer.merge(AuthTokenCoerceLayer(allowAdminToken), InstantHttpLayer), + Layer.merge( + AuthTokenLayer({ allowAdminToken, coerce }), + InstantHttpLayer, + ), ), BaseLayerLive, ); @@ -69,6 +91,7 @@ export const WithAppLayer = (args: { appId?: string; title?: string; coerce: boolean; + coerceAuth?: boolean; packageName?: keyof typeof PACKAGE_ALIAS_AND_FULL_NAMES; applyEnv?: boolean; }) => @@ -81,6 +104,11 @@ export const WithAppLayer = (args: { }), ).pipe( Layer.provideMerge(GlobalOptsLive), - Layer.provideMerge(AuthLayerLive(true)), + Layer.provideMerge( + AuthLayerLive({ + allowAdminToken: true, + coerce: args.coerceAuth || false, + }), + ), Layer.provideMerge(ProjectInfoLive(args.coerce, args.packageName)), ); diff --git a/client/packages/cli/src/new/lib/handleEnv.ts b/client/packages/cli/src/new/lib/handleEnv.ts index c6d8bc1b46..506303b3fa 100644 --- a/client/packages/cli/src/new/lib/handleEnv.ts +++ b/client/packages/cli/src/new/lib/handleEnv.ts @@ -83,6 +83,9 @@ const detectEnvType = Effect.gen(function* () { if (pkgJson.dependencies?.nuxt) { return 'nuxt'; } + if (pkgJson.dependencies?.['@types/bun']) { + return 'bun'; + } return 'catchall'; }).pipe(Effect.catchTag('ProjectInfoError', () => Effect.succeed('catchall'))); diff --git a/client/packages/cli/src/new/lib/http.ts b/client/packages/cli/src/new/lib/http.ts index a54237b9c6..a478fc6afc 100644 --- a/client/packages/cli/src/new/lib/http.ts +++ b/client/packages/cli/src/new/lib/http.ts @@ -34,6 +34,9 @@ export const InstantHttpLive = Layer.effect( HttpClientRequest.setHeader('X-Instant-Version', version), ), ), + HttpClient.transformResponse((r) => + r.pipe(Effect.timeout('5 minutes'), Effect.orDie), + ), HttpClient.filterStatusOk, // makes non 2xx http codes error ); }), diff --git a/client/packages/cli/src/new/lib/pushPerms.ts b/client/packages/cli/src/new/lib/pushPerms.ts index 7821a6630c..c0edc8e7ab 100644 --- a/client/packages/cli/src/new/lib/pushPerms.ts +++ b/client/packages/cli/src/new/lib/pushPerms.ts @@ -11,7 +11,6 @@ import { CurrentApp } from '../context/currentApp.js'; import { promptOk } from './ui.js'; import boxen from 'boxen'; import chalk from 'chalk'; -import { parseSchemaFromJSON } from '@instantdb/core'; export class NoPermsFileError extends Schema.TaggedError<NoPermsFileError>( 'NoPermsFileError', From f10f0f3a03edf715f8bdefb59a9868602540a460 Mon Sep 17 00:00:00 2001 From: Drew Harris <drew@drewh.net> Date: Thu, 12 Mar 2026 16:44:02 -0700 Subject: [PATCH 03/15] add log to explorer command --- client/packages/cli/src/new/commands/explorer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client/packages/cli/src/new/commands/explorer.ts b/client/packages/cli/src/new/commands/explorer.ts index 323eaa1c40..1746c8c2f6 100644 --- a/client/packages/cli/src/new/commands/explorer.ts +++ b/client/packages/cli/src/new/commands/explorer.ts @@ -9,6 +9,7 @@ export const explorerCmd = (opts: OptsFromCommand<typeof explorerDef>) => Effect.gen(function* () { const { appId } = yield* CurrentApp; const dashUrl = yield* getDashUrl; + yield* Effect.log('Opening Explorer...'); const url = `${dashUrl}/dash?s=main&app=${appId}&t=explorer`; yield* Effect.tryPromise(() => openInBrowser(url)).pipe( Effect.catchAll(() => From e632cf13ac6d91fd9b885dee30c1bf5eae1eee82 Mon Sep 17 00:00:00 2001 From: Drew Harris <drew@drewh.net> Date: Thu, 12 Mar 2026 16:48:59 -0700 Subject: [PATCH 04/15] remove @effect/cli dependency --- client/packages/cli/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/client/packages/cli/package.json b/client/packages/cli/package.json index 7ea86559ac..0a1c605be4 100644 --- a/client/packages/cli/package.json +++ b/client/packages/cli/package.json @@ -23,7 +23,6 @@ }, "dependencies": { "@commander-js/extra-typings": "^14.0.0", - "@effect/cli": "^0.73.0", "@effect/platform": "^0.94.1", "@effect/platform-node": "^0.104.0", "@instantdb/core": "workspace:*", From 07278ef9cac77727d389e6e76105546771be89ec Mon Sep 17 00:00:00 2001 From: Drew Harris <drew@drewh.net> Date: Thu, 12 Mar 2026 16:44:02 -0700 Subject: [PATCH 05/15] fix coerce auth for init and browser commands --- client/packages/cli/src/new/commands/explorer.ts | 1 + client/packages/cli/src/new/commands/init.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/client/packages/cli/src/new/commands/explorer.ts b/client/packages/cli/src/new/commands/explorer.ts index 1746c8c2f6..328f2e4dc2 100644 --- a/client/packages/cli/src/new/commands/explorer.ts +++ b/client/packages/cli/src/new/commands/explorer.ts @@ -22,6 +22,7 @@ export const explorerCmd = (opts: OptsFromCommand<typeof explorerDef>) => Effect.provide( WithAppLayer({ coerce: true, + coerceAuth: true, appId: opts.app, }), ), diff --git a/client/packages/cli/src/new/commands/init.ts b/client/packages/cli/src/new/commands/init.ts index 6decc94f58..b90efba089 100644 --- a/client/packages/cli/src/new/commands/init.ts +++ b/client/packages/cli/src/new/commands/init.ts @@ -59,6 +59,7 @@ export const initCommand = (options: OptsFromCommand<typeof initDef>) => Effect.provide( WithAppLayer({ coerce: true, + coerceAuth: true, title: options.title, appId: options.app, packageName: options.package as any, From 42d4273a4622d2761ff56a8c0b12c42bc06f714b Mon Sep 17 00:00:00 2001 From: Drew Harris <drew@drewh.net> Date: Fri, 13 Mar 2026 17:07:13 -0700 Subject: [PATCH 06/15] actually update pnpm lock --- client/pnpm-lock.yaml | 59 ------------------------------------------- 1 file changed, 59 deletions(-) diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 1752a08930..4d54384232 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -72,9 +72,6 @@ importers: '@commander-js/extra-typings': specifier: ^14.0.0 version: 14.0.0(commander@12.1.0) - '@effect/cli': - specifier: ^0.73.0 - version: 0.73.0(@effect/platform@0.94.1(effect@3.19.14))(@effect/printer-ansi@0.47.0(@effect/typeclass@0.38.0(effect@3.19.14))(effect@3.19.14))(@effect/printer@0.47.0(@effect/typeclass@0.38.0(effect@3.19.14))(effect@3.19.14))(effect@3.19.14) '@effect/platform': specifier: ^0.94.1 version: 0.94.1(effect@3.19.14) @@ -2950,14 +2947,6 @@ packages: search-insights: optional: true - '@effect/cli@0.73.0': - resolution: {integrity: sha512-KkRtFjfyG52kQ6Z3ZEjwytfKZQACsLzn/RI2jKKxMJDebY9BQganOiE3VGCT4slU3Zisur6SP//ghM0vCLQs4g==} - peerDependencies: - '@effect/platform': ^0.94.0 - '@effect/printer': ^0.47.0 - '@effect/printer-ansi': ^0.47.0 - effect: ^3.19.13 - '@effect/cluster@0.56.1': resolution: {integrity: sha512-gnrsH6kfrUjn+82j/bw1IR4yFqJqV8tc7xZvrbJPRgzANycc6K1hu3LMg548uYbUkTzD8YYyqrSatMO1mkQpzw==} peerDependencies: @@ -3007,18 +2996,6 @@ packages: peerDependencies: effect: ^3.19.14 - '@effect/printer-ansi@0.47.0': - resolution: {integrity: sha512-tDEQ9XJpXDNYoWMQJHFRMxKGmEOu6z32x3Kb8YLOV5nkauEKnKmWNs7NBp8iio/pqoJbaSwqDwUg9jXVquxfWQ==} - peerDependencies: - '@effect/typeclass': ^0.38.0 - effect: ^3.19.0 - - '@effect/printer@0.47.0': - resolution: {integrity: sha512-VgR8e+YWWhMEAh9qFOjwiZ3OXluAbcVLIOtvp2S5di1nSrPOZxj78g8LE77JSvyfp5y5bS2gmFW+G7xD5uU+2Q==} - peerDependencies: - '@effect/typeclass': ^0.38.0 - effect: ^3.19.0 - '@effect/rpc@0.73.0': resolution: {integrity: sha512-iMPf6tTriz8sK0l5x4koFId8Hz5nFptHYg8WqyjHGIIVLTpZxuiSqhmXZG7FnAs5N2n6uCEws4wWGcIgXNUrFg==} peerDependencies: @@ -3032,11 +3009,6 @@ packages: '@effect/platform': ^0.94.0 effect: ^3.19.13 - '@effect/typeclass@0.38.0': - resolution: {integrity: sha512-lMUcJTRtG8KXhXoczapZDxbLK5os7M6rn0zkvOgncJW++A0UyelZfMVMKdT5R+fgpZcsAU/1diaqw3uqLJwGxA==} - peerDependencies: - effect: ^3.19.0 - '@effect/workflow@0.16.0': resolution: {integrity: sha512-MiAdlxx3TixkgHdbw+Yf1Z3tHAAE0rOQga12kIydJqj05Fnod+W/I+kQGRMY/XWRg+QUsVxhmh1qTr7Ype6lrw==} peerDependencies: @@ -11456,10 +11428,6 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - ini@4.1.3: - resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - inline-style-parser@0.2.4: resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} @@ -19656,16 +19624,6 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' - '@effect/cli@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(@effect/printer-ansi@0.47.0(@effect/typeclass@0.38.0(effect@3.19.14))(effect@3.19.14))(@effect/printer@0.47.0(@effect/typeclass@0.38.0(effect@3.19.14))(effect@3.19.14))(effect@3.19.14)': - dependencies: - '@effect/platform': 0.94.1(effect@3.19.14) - '@effect/printer': 0.47.0(@effect/typeclass@0.38.0(effect@3.19.14))(effect@3.19.14) - '@effect/printer-ansi': 0.47.0(@effect/typeclass@0.38.0(effect@3.19.14))(effect@3.19.14) - effect: 3.19.14 - ini: 4.1.3 - toml: 3.0.0 - yaml: 2.7.0 - '@effect/cluster@0.56.1(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(effect@3.19.14)': dependencies: '@effect/platform': 0.94.1(effect@3.19.14) @@ -19721,17 +19679,6 @@ snapshots: msgpackr: 1.11.8 multipasta: 0.2.7 - '@effect/printer-ansi@0.47.0(@effect/typeclass@0.38.0(effect@3.19.14))(effect@3.19.14)': - dependencies: - '@effect/printer': 0.47.0(@effect/typeclass@0.38.0(effect@3.19.14))(effect@3.19.14) - '@effect/typeclass': 0.38.0(effect@3.19.14) - effect: 3.19.14 - - '@effect/printer@0.47.0(@effect/typeclass@0.38.0(effect@3.19.14))(effect@3.19.14)': - dependencies: - '@effect/typeclass': 0.38.0(effect@3.19.14) - effect: 3.19.14 - '@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)': dependencies: '@effect/platform': 0.94.1(effect@3.19.14) @@ -19745,10 +19692,6 @@ snapshots: effect: 3.19.14 uuid: 11.1.0 - '@effect/typeclass@0.38.0(effect@3.19.14)': - dependencies: - effect: 3.19.14 - '@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14)': dependencies: '@effect/experimental': 0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14)(ioredis@5.6.1) @@ -29571,8 +29514,6 @@ snapshots: ini@4.1.1: {} - ini@4.1.3: {} - inline-style-parser@0.2.4: {} inline-style-prefixer@6.0.4: From a289f6faedf08cbf915201bacd8c1eba05a2684b Mon Sep 17 00:00:00 2001 From: Drew Harris <drew@drewh.net> Date: Mon, 16 Mar 2026 10:01:08 -0700 Subject: [PATCH 07/15] fix a lot --- .../cli/src/new/commands/initWithoutFiles.ts | 137 ++++++++++-------- client/packages/cli/src/new/lib/createApp.ts | 36 +++-- client/packages/cli/src/new/lib/http.ts | 20 ++- client/packages/cli/src/new/lib/login.ts | 4 +- 4 files changed, 118 insertions(+), 79 deletions(-) diff --git a/client/packages/cli/src/new/commands/initWithoutFiles.ts b/client/packages/cli/src/new/commands/initWithoutFiles.ts index fcc411ab19..986fcb869c 100644 --- a/client/packages/cli/src/new/commands/initWithoutFiles.ts +++ b/client/packages/cli/src/new/commands/initWithoutFiles.ts @@ -6,71 +6,88 @@ import { createApp } from '../lib/createApp.js'; import { AuthLayerLive } from '../layer.js'; import chalk from 'chalk'; -export const initWithoutFilesCommand = Effect.fn(function* ( +export const initWithoutFilesCommand = ( opts: OptsFromCommand<typeof initWithoutFilesDef>, -) { - if (!opts?.title) { - return yield* BadArgsError.make({ - message: 'Title is required for creating a new app without local files.', - }); - } +) => + Effect.gen(function* () { + if (!opts?.title) { + return yield* BadArgsError.make({ + message: + 'Title is required for creating a new app without local files.', + }); + } - if (opts.title.startsWith('-')) { - return yield* BadArgsError.make({ - message: `Invalid title: "${opts.title}". Title cannot be a flag.`, - }); - } + if (opts.title.startsWith('-')) { + return yield* BadArgsError.make({ + message: `Invalid title: "${opts.title}". Title cannot be a flag.`, + }); + } - if (opts?.temp && opts?.orgId) { - return yield* BadArgsError.make({ - message: 'Cannot use --temp and --org-id flags together.', - }); - } + if (opts?.temp && opts?.orgId) { + return yield* BadArgsError.make({ + message: 'Cannot use --temp and --org-id flags together.', + }); + } - if (!opts.temp) { - const app = yield* createApp(opts.title, opts.orgId).pipe( - Effect.provide( - AuthLayerLive({ - allowAdminToken: false, - coerce: false, - }), - ), - ); - console.error(`${chalk.green('Successfully created new app!')}\n`); - yield* Effect.log( - JSON.stringify( - { - app: { - appId: app.app.id, - adminToken: app.app['admin-token'], + if (!opts.temp) { + const app = yield* createApp(opts.title, opts.orgId).pipe( + Effect.provide( + AuthLayerLive({ + allowAdminToken: false, + coerce: false, + }), + ), + ); + console.error(`${chalk.green('Successfully created new app!')}\n`); + yield* Effect.log( + JSON.stringify( + { + app: { + appId: app.app.id, + adminToken: app.app['admin-token'], + }, + error: null, }, - error: null, - }, - null, - 2, - ), - ); - } else { - const platform = yield* PlatformApi; - const app = yield* platform.use((api) => - api.createTemporaryApp({ - title: opts.title!, - }), - ); + null, + 2, + ), + ); + } else { + const platform = yield* PlatformApi; + const app = yield* platform.use((api) => + api.createTemporaryApp({ + title: opts.title!, + }), + ); - console.error(`${chalk.green('Successfully created new app!')}\n`); - yield* Effect.log( - JSON.stringify( - { - app: { - appId: app.app.id, - adminToken: app.app.adminToken, + console.error(`${chalk.green('Successfully created new app!')}\n`); + yield* Effect.log( + JSON.stringify( + { + app: { + appId: app.app.id, + adminToken: app.app.adminToken, + }, + error: null, + }, + null, + 2, + ), + ); + } + }).pipe( + Effect.catchAll((e) => + Effect.log( + JSON.stringify( + { + app: null, + error: { + message: e.message, + }, }, - error: null, - }, - null, - 2, + null, + 2, + ), ), - ); - } -}); + ), + ); diff --git a/client/packages/cli/src/new/lib/createApp.ts b/client/packages/cli/src/new/lib/createApp.ts index 5ddcab6ea5..d35fa7d364 100644 --- a/client/packages/cli/src/new/lib/createApp.ts +++ b/client/packages/cli/src/new/lib/createApp.ts @@ -1,18 +1,28 @@ import { randomUUID } from 'crypto'; -import { Effect, Schema } from 'effect'; +import { Data, Effect, Schema } from 'effect'; import { InstantHttpAuthed } from './http.js'; import { HttpClientRequest, HttpClientResponse } from '@effect/platform'; -export const createApp = Effect.fn(function* (title: string, orgId?: string) { - const http = yield* InstantHttpAuthed; - const id = randomUUID(); - const token = randomUUID(); - const app = { id, title, admin_token: token, org_id: orgId }; +export class CreateAppError extends Data.TaggedError('CreateAppError')<{ + message: string; +}> {} - const res = yield* HttpClientRequest.post('/dash/apps').pipe( - HttpClientRequest.bodyJson(app), - Effect.flatMap(http.execute), - Effect.flatMap(HttpClientResponse.schemaBodyJson(Schema.Any)), - ); - return res; -}); +export const createApp = Effect.fn( + function* (title: string, orgId?: string) { + const http = yield* InstantHttpAuthed; + const id = randomUUID(); + const token = randomUUID(); + const app = { id, title, admin_token: token, org_id: orgId }; + + const res = yield* HttpClientRequest.post('/dash/apps').pipe( + HttpClientRequest.bodyJson(app), + Effect.flatMap(http.execute), + Effect.flatMap(HttpClientResponse.schemaBodyJson(Schema.Any)), + ); + return res; + }, + Effect.catchTag( + 'HttpBodyError', + (e) => new CreateAppError({ message: 'Error constructing http body' }), + ), +); diff --git a/client/packages/cli/src/new/lib/http.ts b/client/packages/cli/src/new/lib/http.ts index a478fc6afc..644a0defb8 100644 --- a/client/packages/cli/src/new/lib/http.ts +++ b/client/packages/cli/src/new/lib/http.ts @@ -1,19 +1,23 @@ import { HttpClient, HttpClientRequest } from '@effect/platform'; import { version } from '@instantdb/version'; -import { Config, Context, Effect, Layer, Option } from 'effect'; +import { Config, Context, Data, Effect, Layer, Option } from 'effect'; import { AuthToken } from '../context/authToken.js'; export class InstantHttp extends Context.Tag( 'instant-cli/new/lib/http/InstantHttp', -)<InstantHttp, HttpClient.HttpClient>() {} +)<InstantHttp, HttpClient.HttpClient.With<InstantHttpError>>() {} export class InstantHttpAuthed extends Context.Tag( 'instant-cli/new/lib/http/InstantHttpAuthed', -)<InstantHttpAuthed, HttpClient.HttpClient>() {} +)<InstantHttpAuthed, HttpClient.HttpClient.With<InstantHttpError>>() {} + +export class InstantHttpError extends Data.TaggedError('InstantHttpError')<{ + message: string; +}> {} // Pipe on a client to set command header export const withCommand = (command: string) => { - return (client: HttpClient.HttpClient) => + return (client: HttpClient.HttpClient.With<InstantHttpError>) => client.pipe( HttpClient.mapRequest((r) => r.pipe(HttpClientRequest.setHeader(`X-Instant-Command`, command)), @@ -38,6 +42,14 @@ export const InstantHttpLive = Layer.effect( r.pipe(Effect.timeout('5 minutes'), Effect.orDie), ), HttpClient.filterStatusOk, // makes non 2xx http codes error + HttpClient.transformResponse((r) => + r.pipe( + Effect.mapError((e) => { + console.log(e.response.toJSON()); + return new InstantHttpError({ message: e.message }); + }), + ), + ), ); }), ); diff --git a/client/packages/cli/src/new/lib/login.ts b/client/packages/cli/src/new/lib/login.ts index b9a498e20d..3925418917 100644 --- a/client/packages/cli/src/new/lib/login.ts +++ b/client/packages/cli/src/new/lib/login.ts @@ -29,10 +29,10 @@ export const getLoginTicketAndSecret = Effect.gen(function* () { export const waitForAuthToken = Effect.fn(function* (secret: string) { const http = (yield* InstantHttp).pipe(withCommand('login')); const res = yield* HttpClientRequest.post('/dash/cli/auth/check').pipe( - HttpClientRequest.bodyJson({ + HttpClientRequest.bodyUnsafeJson({ secret, }), - Effect.flatMap(http.execute), + http.execute, Effect.flatMap(HttpClientResponse.schemaBodyJson(TokenResult)), Effect.retry({ schedule: Schedule.fixed('1 seconds'), From 04879eb1654a2d897f4b9e107e4758dcff941e16 Mon Sep 17 00:00:00 2001 From: Drew Harris <drew@drewh.net> Date: Mon, 16 Mar 2026 10:25:17 -0700 Subject: [PATCH 08/15] super smart http errors --- .../cli/src/new/commands/initWithoutFiles.ts | 7 ++++ .../cli/src/new/context/projectInfo.ts | 2 ++ client/packages/cli/src/new/lib/http.ts | 36 ++++++++++++++++--- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/client/packages/cli/src/new/commands/initWithoutFiles.ts b/client/packages/cli/src/new/commands/initWithoutFiles.ts index 986fcb869c..c289cd2e6d 100644 --- a/client/packages/cli/src/new/commands/initWithoutFiles.ts +++ b/client/packages/cli/src/new/commands/initWithoutFiles.ts @@ -5,6 +5,7 @@ import { OptsFromCommand, initWithoutFilesDef } from '../index.js'; import { createApp } from '../lib/createApp.js'; import { AuthLayerLive } from '../layer.js'; import chalk from 'chalk'; +import { NotAuthedError } from '../context/authToken.js'; export const initWithoutFilesCommand = ( opts: OptsFromCommand<typeof initWithoutFilesDef>, @@ -76,6 +77,12 @@ export const initWithoutFilesCommand = ( ); } }).pipe( + Effect.catchTag('NotAuthedError', (e) => + NotAuthedError.make({ + message: + 'Please log in first with `instant-cli login` before running this command.', + }), + ), Effect.catchAll((e) => Effect.log( JSON.stringify( diff --git a/client/packages/cli/src/new/context/projectInfo.ts b/client/packages/cli/src/new/context/projectInfo.ts index 7896b8919e..eebb88075b 100644 --- a/client/packages/cli/src/new/context/projectInfo.ts +++ b/client/packages/cli/src/new/context/projectInfo.ts @@ -105,6 +105,7 @@ const getProjectInfo = ( }, { label: '@instantdb/core', value: '@instantdb/core' }, { label: '@instantdb/admin', value: '@instantdb/admin' }, + { label: '@instantdb/solidjs', value: '@instantdb/solidjs' }, ], }), ).pipe( @@ -166,6 +167,7 @@ function getInstantModuleName(pkgJson: PackageJson) { '@instantdb/react-native', '@instantdb/core', '@instantdb/admin', + '@instantdb/solidjs', ].find((name) => deps[name] || devDeps[name]); return instantModuleName; } diff --git a/client/packages/cli/src/new/lib/http.ts b/client/packages/cli/src/new/lib/http.ts index 644a0defb8..d2c453b9f5 100644 --- a/client/packages/cli/src/new/lib/http.ts +++ b/client/packages/cli/src/new/lib/http.ts @@ -1,6 +1,6 @@ import { HttpClient, HttpClientRequest } from '@effect/platform'; import { version } from '@instantdb/version'; -import { Config, Context, Data, Effect, Layer, Option } from 'effect'; +import { Config, Context, Data, Effect, Layer, Option, Schema } from 'effect'; import { AuthToken } from '../context/authToken.js'; export class InstantHttp extends Context.Tag( @@ -13,6 +13,8 @@ export class InstantHttpAuthed extends Context.Tag( export class InstantHttpError extends Data.TaggedError('InstantHttpError')<{ message: string; + type: string; + methodAndUrl: string; }> {} // Pipe on a client to set command header @@ -25,6 +27,11 @@ export const withCommand = (command: string) => { ); }; +class InstantTypicalHttpErrorResponse extends Schema.Struct({ + message: Schema.String, + type: Schema.String.pipe(Schema.optional), +}) {} + export const InstantHttpLive = Layer.effect( InstantHttp, Effect.gen(function* () { @@ -44,10 +51,29 @@ export const InstantHttpLive = Layer.effect( HttpClient.filterStatusOk, // makes non 2xx http codes error HttpClient.transformResponse((r) => r.pipe( - Effect.mapError((e) => { - console.log(e.response.toJSON()); - return new InstantHttpError({ message: e.message }); - }), + Effect.catchAll((requestError) => + Effect.gen(function* () { + const jsonBody = yield* requestError.response.json.pipe( + Effect.andThen( + Schema.decodeUnknown(InstantTypicalHttpErrorResponse), + ), + Effect.mapError( + (e) => + new InstantHttpError({ + message: + 'Error making request to ' + requestError.methodAndUrl, + type: e._tag, + methodAndUrl: requestError.methodAndUrl, + }), + ), + ); + return yield* new InstantHttpError({ + message: jsonBody.message, + methodAndUrl: requestError.methodAndUrl, + type: jsonBody.type || 'Unknown type', + }); + }), + ), ), ), ); From e0ffe4a3635b244133b58f7edcc83514ddbdbc9e Mon Sep 17 00:00:00 2001 From: Drew Harris <drew@drewh.net> Date: Mon, 16 Mar 2026 10:59:45 -0700 Subject: [PATCH 09/15] schema validation errors --- client/packages/cli/src/new/commands/login.ts | 2 +- client/packages/cli/src/new/lib/pushSchema.ts | 42 +++++++++++++------ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/client/packages/cli/src/new/commands/login.ts b/client/packages/cli/src/new/commands/login.ts index fd440521a4..93bde3fdc9 100644 --- a/client/packages/cli/src/new/commands/login.ts +++ b/client/packages/cli/src/new/commands/login.ts @@ -49,7 +49,7 @@ export const loginCommand = Effect.fn(function* ( true, ); if (!ok) { - process.exit(1); + process.exit(0); } yield* Effect.tryPromise(() => openInBrowser(`${dashOrigin}/dash?ticket=${ticket}`), diff --git a/client/packages/cli/src/new/lib/pushSchema.ts b/client/packages/cli/src/new/lib/pushSchema.ts index dc319f856f..2e5175265a 100644 --- a/client/packages/cli/src/new/lib/pushSchema.ts +++ b/client/packages/cli/src/new/lib/pushSchema.ts @@ -19,8 +19,10 @@ import { collectSystemCatalogIdentNames, convertTxSteps, diffSchemas, + SchemaValidationError as PlatformSchemaError, validateSchema, } from '@instantdb/platform'; + import { OptsFromCommand, pushDef } from '../index.js'; import { GlobalOpts } from '../context/globalOpts.js'; import { @@ -69,9 +71,13 @@ export const pushSchema = ( rename?: OptsFromCommand<typeof pushDef>['rename'], ) => Effect.gen(function* () { - const localSchemaFile = yield* Effect.tryPromise(readLocalSchemaFile).pipe( - Effect.mapError((e) => ReadSchemaFileError.make(e)), - ); + const localSchemaFile = yield* Effect.tryPromise({ + try: readLocalSchemaFile, + catch: (e) => + e instanceof Error + ? ReadSchemaFileError.make({ message: e.message }) + : ReadSchemaFileError.make({ message: String(e) }), + }); if (!localSchemaFile || !localSchemaFile?.schema) { error( `We couldn't find your ${chalk.yellow('`instant.schema.ts`')} file. Make sure it's in the root directory. (Hint: You can use an INSTANT_SCHEMA_FILE_PATH environment variable to specify it.)`, @@ -95,7 +101,7 @@ export const pushSchema = ( .pipe(withCommand('push')) .get(`/dash/apps/${appId}/schema/pull`) .pipe( - Effect.flatMap(HttpClientResponse.schemaBodyJson(FetchSchemaResponse)), + Effect.andThen(HttpClientResponse.schemaBodyJson(FetchSchemaResponse)), Effect.mapError((e) => GetSchemaError.make({ message: e.message })), ); @@ -107,13 +113,20 @@ export const pushSchema = ( const systemCatalogIdentNames = collectSystemCatalogIdentNames(currentAttrs); - yield* Effect.tryPromise(async () => - validateSchema(localSchemaFile.schema, systemCatalogIdentNames), - ).pipe( - Effect.mapError((e) => - SchemaValidationError.make({ message: 'Invalid Schema: ' + e.message }), - ), - ); + yield* Effect.tryPromise({ + try: async () => + validateSchema(localSchemaFile.schema, systemCatalogIdentNames), + catch: (e) => { + if (e instanceof PlatformSchemaError) { + return SchemaValidationError.make({ + message: 'Invalid Schema: ' + e.message, + }); + } + return SchemaValidationError.make({ + message: 'Failed to validate schema' + e, + }); + }, + }); const renames = rename && Array.isArray(rename) ? rename : []; const globalOpts = yield* GlobalOpts; const renameSelector = globalOpts.yes @@ -128,7 +141,12 @@ export const pushSchema = ( systemCatalogIdentNames, ), ).pipe( - Effect.mapError((e) => SchemaDiffError.make({ message: e.message })), + Effect.mapError((e) => { + if (e.cause instanceof Error) { + return SchemaDiffError.make({ message: e.cause.message }); + } + return e.message; + }), ); const txSteps = convertTxSteps(diffResult, currentAttrs); From 9a4a708dd30182c4378c0561f9ea6bd4097f7512 Mon Sep 17 00:00:00 2001 From: Drew Harris <drew@drewh.net> Date: Mon, 16 Mar 2026 11:18:06 -0700 Subject: [PATCH 10/15] add svelte after rebase --- client/packages/cli/src/new/context/projectInfo.ts | 2 ++ client/packages/cli/src/new/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/client/packages/cli/src/new/context/projectInfo.ts b/client/packages/cli/src/new/context/projectInfo.ts index eebb88075b..76f334cb4c 100644 --- a/client/packages/cli/src/new/context/projectInfo.ts +++ b/client/packages/cli/src/new/context/projectInfo.ts @@ -28,6 +28,7 @@ export const PACKAGE_ALIAS_AND_FULL_NAMES = { core: '@instantdb/core', admin: '@instantdb/admin', solid: '@instantdb/solidjs', + svelte: '@instantdb/svelte', }; export class ProjectInfoError extends Data.TaggedError('ProjectInfoError')<{ @@ -106,6 +107,7 @@ const getProjectInfo = ( { label: '@instantdb/core', value: '@instantdb/core' }, { label: '@instantdb/admin', value: '@instantdb/admin' }, { label: '@instantdb/solidjs', value: '@instantdb/solidjs' }, + { label: '@instantdb/svelte', value: '@instantdb/svelte' }, ], }), ).pipe( diff --git a/client/packages/cli/src/new/index.ts b/client/packages/cli/src/new/index.ts index 13c0315414..ca2c4c4d4b 100644 --- a/client/packages/cli/src/new/index.ts +++ b/client/packages/cli/src/new/index.ts @@ -133,7 +133,7 @@ export const pullDef = program 'App ID to push to. Defaults to *_INSTANT_APP_ID in .env', ) .option( - '-p --package <react|react-native|core|admin|solid>', + '-p --package <react|react-native|core|admin|solid|svelte>', 'Which package to automatically install if there is not one installed already.', ) .option( @@ -172,7 +172,7 @@ export const pushDef = program 'List of full attribute names separated by a ":"\n Example:`push --rename posts.author:posts.creator stores.owner:stores.manager`', ) .option( - '-p --package <react|react-native|core|admin|solid>', + '-p --package <react|react-native|core|admin|solid|svelte>', 'Which package to automatically install if there is not one installed already.', ) .description('Push schema and perm files to production.') From a29f6d82e2da496a165180353826c8fd452ddbd1 Mon Sep 17 00:00:00 2001 From: Drew Harris <drew@drewh.net> Date: Mon, 16 Mar 2026 17:00:55 -0700 Subject: [PATCH 11/15] fix lockfile --- client/pnpm-lock.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 4d54384232..24163130f0 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -19652,7 +19652,7 @@ snapshots: '@parcel/watcher': 2.5.1 effect: 3.19.14 multipasta: 0.2.7 - ws: 8.18.2 + ws: 8.19.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -19667,7 +19667,7 @@ snapshots: effect: 3.19.14 mime: 3.0.0 undici: 7.18.2 - ws: 8.18.2 + ws: 8.19.0 transitivePeerDependencies: - bufferutil - utf-8-validate From e6e81f9513c737042340ffe8323333f8a2d781d6 Mon Sep 17 00:00:00 2001 From: Drew Harris <drew@drewh.net> Date: Tue, 17 Mar 2026 16:32:03 -0700 Subject: [PATCH 12/15] fix small issues --- client/packages/cli/src/new/commands/claim.ts | 1 + .../packages/cli/src/new/context/authToken.ts | 23 ++++++++++--------- .../cli/src/new/context/currentApp.ts | 13 ++--------- .../cli/src/new/context/projectInfo.ts | 1 + client/packages/cli/src/new/layer.ts | 3 ++- 5 files changed, 18 insertions(+), 23 deletions(-) diff --git a/client/packages/cli/src/new/commands/claim.ts b/client/packages/cli/src/new/commands/claim.ts index 4485d86abd..2ff79b9b32 100644 --- a/client/packages/cli/src/new/commands/claim.ts +++ b/client/packages/cli/src/new/commands/claim.ts @@ -33,6 +33,7 @@ export const claimCommand = Effect.gen(function* () { Effect.provide( WithAppLayer({ coerce: false, + allowAdminToken: false, applyEnv: false, }), ), diff --git a/client/packages/cli/src/new/context/authToken.ts b/client/packages/cli/src/new/context/authToken.ts index 1101e43dec..380ba32272 100644 --- a/client/packages/cli/src/new/context/authToken.ts +++ b/client/packages/cli/src/new/context/authToken.ts @@ -41,6 +41,18 @@ export const authTokenGetEffect = (allowAdminToken: boolean = true) => }; } + const secondaryEnv = yield* Config.string('INSTANT_APP_ADMIN_TOKEN').pipe( + Config.orElse(() => Config.string('INSTANT_ADMIN_TOKEN')), + Config.option, + Config.map(Option.getOrNull), + ); + if (secondaryEnv && allowAdminToken) { + return { + authToken: secondaryEnv, + source: 'admin' as 'admin', + }; + } + const authPaths = yield* getAuthPaths; const fs = yield* FileSystem.FileSystem; const file = yield* fs @@ -56,17 +68,6 @@ export const authTokenGetEffect = (allowAdminToken: boolean = true) => }; } - const secondaryEnv = yield* Config.string('INSTANT_APP_ADMIN_TOKEN').pipe( - Config.orElse(() => Config.string('INSTANT_ADMIN_TOKEN')), - Config.option, - Config.map(Option.getOrNull), - ); - if (secondaryEnv && allowAdminToken) { - return { - authToken: secondaryEnv, - source: 'admin' as 'admin', - }; - } return yield* NotAuthedError.make({ message: 'You are not logged in' }); }); diff --git a/client/packages/cli/src/new/context/currentApp.ts b/client/packages/cli/src/new/context/currentApp.ts index 180c064d0f..f4cb1e23bf 100644 --- a/client/packages/cli/src/new/context/currentApp.ts +++ b/client/packages/cli/src/new/context/currentApp.ts @@ -1,18 +1,9 @@ -import { - HttpBody, - HttpClientRequest, - HttpClientResponse, -} from '@effect/platform'; +import { HttpClientRequest, HttpClientResponse } from '@effect/platform'; import { randomUUID } from 'crypto'; import { Context, Data, Effect, Layer, Runtime, Schema, Option } from 'effect'; import { UI } from '../../ui/index.js'; import { handleEnv } from '../lib/handleEnv.js'; -import { - getBaseUrl, - InstantHttp, - InstantHttpAuthed, - withCommand, -} from '../lib/http.js'; +import { getBaseUrl, InstantHttpAuthed, withCommand } from '../lib/http.js'; import { runUIEffect } from '../lib/ui.js'; import { AuthToken } from './authToken.js'; import { GlobalOpts } from './globalOpts.js'; diff --git a/client/packages/cli/src/new/context/projectInfo.ts b/client/packages/cli/src/new/context/projectInfo.ts index 76f334cb4c..84ed10476b 100644 --- a/client/packages/cli/src/new/context/projectInfo.ts +++ b/client/packages/cli/src/new/context/projectInfo.ts @@ -170,6 +170,7 @@ function getInstantModuleName(pkgJson: PackageJson) { '@instantdb/core', '@instantdb/admin', '@instantdb/solidjs', + '@instantdb/svelte', ].find((name) => deps[name] || devDeps[name]); return instantModuleName; } diff --git a/client/packages/cli/src/new/layer.ts b/client/packages/cli/src/new/layer.ts index a5259f37a0..6d1fa31cc6 100644 --- a/client/packages/cli/src/new/layer.ts +++ b/client/packages/cli/src/new/layer.ts @@ -93,6 +93,7 @@ export const WithAppLayer = (args: { coerce: boolean; coerceAuth?: boolean; packageName?: keyof typeof PACKAGE_ALIAS_AND_FULL_NAMES; + allowAdminToken?: boolean; applyEnv?: boolean; }) => Layer.mergeAll( @@ -106,7 +107,7 @@ export const WithAppLayer = (args: { Layer.provideMerge(GlobalOptsLive), Layer.provideMerge( AuthLayerLive({ - allowAdminToken: true, + allowAdminToken: args.allowAdminToken || true, coerce: args.coerceAuth || false, }), ), From b5ae90af9ae929c35fe3bbea6f6358c2fdde9ab1 Mon Sep 17 00:00:00 2001 From: Drew Harris <drew@drewh.net> Date: Wed, 18 Mar 2026 09:26:54 -0700 Subject: [PATCH 13/15] todo comment look at it --- .../cli/src/new/context/projectInfo.ts | 9 +++++++++ client/packages/cli/src/new/layer.ts | 7 ++++++- client/packages/cli/src/new/lib/http.ts | 18 ++++++++++++------ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/client/packages/cli/src/new/context/projectInfo.ts b/client/packages/cli/src/new/context/projectInfo.ts index 84ed10476b..201d110c9a 100644 --- a/client/packages/cli/src/new/context/projectInfo.ts +++ b/client/packages/cli/src/new/context/projectInfo.ts @@ -8,6 +8,8 @@ import { UI } from '../../ui/index.js'; import { findProjectDir } from '../../util/projectDir.js'; import { runUIEffect } from '../lib/ui.js'; import chalk from 'chalk'; +import { GlobalOpts } from './globalOpts.js'; +import { BadArgsError } from '../errors.js'; export class ProjectInfo extends Context.Tag( 'instant-cli/new/context/projectInfo', @@ -90,11 +92,18 @@ const getProjectInfo = ( ), ); + const { yes } = yield* GlobalOpts; if (!moduleName && coerce) { // install the packages if (packageName) { moduleName = PACKAGE_ALIAS_AND_FULL_NAMES[packageName]; } else { + if (yes) { + return yield* BadArgsError.make({ + message: + '--yes was provided without a package specificaion and no Instant SDK was found', + }); + } moduleName = yield* runUIEffect( new UI.Select({ promptText: 'Which package would you like to use?', diff --git a/client/packages/cli/src/new/layer.ts b/client/packages/cli/src/new/layer.ts index 6d1fa31cc6..30d50877b9 100644 --- a/client/packages/cli/src/new/layer.ts +++ b/client/packages/cli/src/new/layer.ts @@ -20,9 +20,14 @@ export const runCommandEffect = <A, E, R extends never>( export const printRedErrors = Effect.catchAllCause((cause) => { const failure = Cause.failureOption(cause); + if (failure._tag !== 'Some') { + return Effect.succeed(undefined); + } + + const theError = failure.value; + // Print just the message if the error has a message attribute and no cause if ( - failure._tag === 'Some' && typeof failure.value === 'object' && failure.value !== null && 'message' in failure.value && diff --git a/client/packages/cli/src/new/lib/http.ts b/client/packages/cli/src/new/lib/http.ts index d2c453b9f5..ab14d77c79 100644 --- a/client/packages/cli/src/new/lib/http.ts +++ b/client/packages/cli/src/new/lib/http.ts @@ -2,14 +2,22 @@ import { HttpClient, HttpClientRequest } from '@effect/platform'; import { version } from '@instantdb/version'; import { Config, Context, Data, Effect, Layer, Option, Schema } from 'effect'; import { AuthToken } from '../context/authToken.js'; +import { TimeoutException } from 'effect/Cause'; +import { RequestError } from '@effect/platform/HttpClientError'; export class InstantHttp extends Context.Tag( 'instant-cli/new/lib/http/InstantHttp', -)<InstantHttp, HttpClient.HttpClient.With<InstantHttpError>>() {} +)< + InstantHttp, + HttpClient.HttpClient.With<InstantHttpError | TimeoutException | RequestError> +>() {} export class InstantHttpAuthed extends Context.Tag( 'instant-cli/new/lib/http/InstantHttpAuthed', -)<InstantHttpAuthed, HttpClient.HttpClient.With<InstantHttpError>>() {} +)< + InstantHttpAuthed, + HttpClient.HttpClient.With<InstantHttpError | TimeoutException | RequestError> +>() {} export class InstantHttpError extends Data.TaggedError('InstantHttpError')<{ message: string; @@ -45,13 +53,11 @@ export const InstantHttpLive = Layer.effect( HttpClientRequest.setHeader('X-Instant-Version', version), ), ), - HttpClient.transformResponse((r) => - r.pipe(Effect.timeout('5 minutes'), Effect.orDie), - ), + HttpClient.transformResponse((r) => r.pipe(Effect.timeout('5 minutes'))), HttpClient.filterStatusOk, // makes non 2xx http codes error HttpClient.transformResponse((r) => r.pipe( - Effect.catchAll((requestError) => + Effect.catchTag('ResponseError', (requestError) => Effect.gen(function* () { const jsonBody = yield* requestError.response.json.pipe( Effect.andThen( From 9477c9a61c55c2389f6bd63f81b608553262e713 Mon Sep 17 00:00:00 2001 From: Drew Harris <drew@drewh.net> Date: Wed, 18 Mar 2026 10:27:58 -0700 Subject: [PATCH 14/15] changes --- client/packages/cli/src/new/layer.ts | 64 ++++++++++++++++-------- client/packages/cli/src/new/lib/http.ts | 5 ++ client/packages/cli/src/new/lib/login.ts | 3 ++ 3 files changed, 50 insertions(+), 22 deletions(-) diff --git a/client/packages/cli/src/new/layer.ts b/client/packages/cli/src/new/layer.ts index 30d50877b9..b3e317ac69 100644 --- a/client/packages/cli/src/new/layer.ts +++ b/client/packages/cli/src/new/layer.ts @@ -8,7 +8,11 @@ import { PACKAGE_ALIAS_AND_FULL_NAMES, ProjectInfoLive, } from './context/projectInfo.js'; -import { InstantHttpAuthedLive, InstantHttpLive } from './lib/http.js'; +import { + InstantHttpAuthedLive, + InstantHttpError, + InstantHttpLive, +} from './lib/http.js'; import { SimpleLogLayer } from './logging.js'; const runtime = ManagedRuntime.make(SimpleLogLayer); @@ -17,34 +21,49 @@ export const runCommandEffect = <A, E, R extends never>( effect: Effect.Effect<A, E, R>, ): Promise<any> => runtime.runPromise(effect.pipe(printRedErrors) as any); -export const printRedErrors = Effect.catchAllCause((cause) => { - const failure = Cause.failureOption(cause); +export const printRedErrors = Effect.catchAllCause((cause) => + Effect.gen(function* () { + const failure = Cause.failureOption(cause); + + // This should never happen because the catchAllCause should only fire when there IS a failure + if (failure._tag !== 'Some') { + return; + } + + const theError = failure.value; - if (failure._tag !== 'Some') { - return Effect.succeed(undefined); - } + // Special error handling for specific error types + if (theError instanceof InstantHttpError) { + return yield* Effect.logError( + `Error making request to Instant API: ${theError.message}`, + ); + } - const theError = failure.value; + // Print just the message if the error has a message attribute and no cause + if ( + typeof failure.value === 'object' && + failure.value !== null && + 'message' in failure.value && + !('cause' in failure.value) + ) { + return yield* Effect.logError( + (failure.value as { message: string }).message, + ).pipe( + Effect.tap(() => { + process.exit(1); + }), + ); + } - // Print just the message if the error has a message attribute and no cause - if ( - typeof failure.value === 'object' && - failure.value !== null && - 'message' in failure.value && - !('cause' in failure.value) - ) { - return Effect.logError((failure.value as { message: string }).message).pipe( + return yield* Effect.logError( + Cause.pretty(cause, { renderErrorCause: true }), + ).pipe( Effect.tap(() => { process.exit(1); }), ); - } - return Effect.logError(Cause.pretty(cause, { renderErrorCause: true })).pipe( - Effect.tap(() => { - process.exit(1); - }), - ); -}); + }), +); /** * Note: @@ -117,4 +136,5 @@ export const WithAppLayer = (args: { }), ), Layer.provideMerge(ProjectInfoLive(args.coerce, args.packageName)), + Layer.provideMerge(BaseLayerLive), ); diff --git a/client/packages/cli/src/new/lib/http.ts b/client/packages/cli/src/new/lib/http.ts index ab14d77c79..e03bf89039 100644 --- a/client/packages/cli/src/new/lib/http.ts +++ b/client/packages/cli/src/new/lib/http.ts @@ -23,6 +23,7 @@ export class InstantHttpError extends Data.TaggedError('InstantHttpError')<{ message: string; type: string; methodAndUrl: string; + hint?: Record<string, any>; }> {} // Pipe on a client to set command header @@ -38,6 +39,9 @@ export const withCommand = (command: string) => { class InstantTypicalHttpErrorResponse extends Schema.Struct({ message: Schema.String, type: Schema.String.pipe(Schema.optional), + hint: Schema.Record({ key: Schema.String, value: Schema.Any }).pipe( + Schema.optional, + ), }) {} export const InstantHttpLive = Layer.effect( @@ -76,6 +80,7 @@ export const InstantHttpLive = Layer.effect( return yield* new InstantHttpError({ message: jsonBody.message, methodAndUrl: requestError.methodAndUrl, + hint: jsonBody.hint, type: jsonBody.type || 'Unknown type', }); }), diff --git a/client/packages/cli/src/new/lib/login.ts b/client/packages/cli/src/new/lib/login.ts index 3925418917..29d555ffb7 100644 --- a/client/packages/cli/src/new/lib/login.ts +++ b/client/packages/cli/src/new/lib/login.ts @@ -35,6 +35,9 @@ export const waitForAuthToken = Effect.fn(function* (secret: string) { http.execute, Effect.flatMap(HttpClientResponse.schemaBodyJson(TokenResult)), Effect.retry({ + while: (e) => + e._tag === 'InstantHttpError' && + e.hint?.errors?.at(0).issue === 'waiting-for-user', schedule: Schedule.fixed('1 seconds'), times: 120, }), From 3eff832030fca81093eacd36747dc1fb20967caa Mon Sep 17 00:00:00 2001 From: Drew Harris <drew@drewh.net> Date: Fri, 20 Mar 2026 16:47:32 -0700 Subject: [PATCH 15/15] add --asToken --- client/packages/cli/src/new/commands/query.ts | 14 ++++++++++---- client/packages/cli/src/new/index.ts | 4 ++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/client/packages/cli/src/new/commands/query.ts b/client/packages/cli/src/new/commands/query.ts index 41cc5c57b1..4ed879f0a0 100644 --- a/client/packages/cli/src/new/commands/query.ts +++ b/client/packages/cli/src/new/commands/query.ts @@ -10,15 +10,19 @@ import { HttpBody } from '@effect/platform'; export const queryCmd = (arg: string, opts: OptsFromCommand<typeof queryDef>) => Effect.gen(function* () { const { appId } = yield* CurrentApp; - const contextCount = - (opts.admin ? 1 : 0) + (opts.asEmail ? 1 : 0) + (opts.asGuest ? 1 : 0); - if (contextCount === 0) { + const contexts = [ + opts.admin, + opts.asEmail, + opts.asGuest, + opts.asToken, + ].filter(Boolean); + if (contexts.length === 0) { return yield* BadArgsError.make({ message: 'Please specify a context: --admin, --as-email <email>, or --as-guest', }); } - if (contextCount > 1) { + if (contexts.length > 1) { return yield* BadArgsError.make({ message: 'Please specify only one context: --admin, --as-email <email>, or --as-guest', @@ -37,6 +41,8 @@ export const queryCmd = (arg: string, opts: OptsFromCommand<typeof queryDef>) => headers['as-email'] = opts.asEmail; } else if (opts.asGuest) { headers['as-guest'] = 'true'; + } else if (opts.asToken) { + headers['as-token'] = opts.asToken; } const http = (yield* InstantHttpAuthed).pipe(withCommand('query')); diff --git a/client/packages/cli/src/new/index.ts b/client/packages/cli/src/new/index.ts index ca2c4c4d4b..cf5d32dbc7 100644 --- a/client/packages/cli/src/new/index.ts +++ b/client/packages/cli/src/new/index.ts @@ -117,6 +117,10 @@ export const queryDef = program .option('--admin', 'Run the query as admin (bypasses permissions)') .option('--as-email <email>', 'Run the query as a specific user by email') .option('--as-guest', 'Run the query as an unauthenticated guest') + .option( + '--as-token <refresh-token>', + 'Run the query as a user identified by refresh token', + ) .description('Run an InstaQL query against your app.') .action(async function (queryArg, opts) { runCommandEffect(queryCmd(queryArg, opts));